Сделал первые функции под MRP
This commit is contained in:
@@ -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{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,22 +31,31 @@ 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 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Status is required"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result := database.DB.Model(&models.Part{}).Where("id = ?", id).Update("status", input.Status)
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Неверные данные"})
|
||||||
|
}
|
||||||
|
|
||||||
if result.RowsAffected == 0{
|
if err := database.DB.Model(&models.Part{}).Where("id = ?", id).Update("status", input.Status).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error" : "Деталь не найдена"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось обновить статус"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "updated", "new_status": input.Status})
|
c.JSON(http.StatusOK, gin.H{"status": "updated", "new_status": input.Status})
|
||||||
@@ -53,7 +64,7 @@ func UpdateStatus(c *gin.Context) {
|
|||||||
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": "Удалено"})
|
||||||
@@ -66,8 +77,20 @@ func CreatePartsBulk(c *gin.Context){
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ordersMap := make(map[string]bool)
|
||||||
|
for _, p := range parts {
|
||||||
|
if p.OrderNo != "" {
|
||||||
|
ordersMap[p.OrderNo] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err := database.DB.Create(&parts).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error" : "Не могу создать деталь"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,17 +99,26 @@ func CreatePartsBulk(c *gin.Context){
|
|||||||
|
|
||||||
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 {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// GORM сделает один быстрый INSERT для всех деталей
|
if len(parts) > 0 {
|
||||||
if err := database.DB.Create(&parts).Error; err != nil {
|
orderNo := parts[0].OrderNo
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка импорта"})
|
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Импортировано", "count": len(parts)})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
+18
-20
@@ -40,29 +40,27 @@ func IsValidStatus(s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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"`
|
||||||
Length float64 `json:"length" binding:"required"` // Готовая деталь [L]
|
Width float64 `json:"width" binding:"required"`
|
||||||
Width float64 `json:"width" binding:"required"` // Готовая деталь [W]
|
LengthFirst float64 `json:"length_first" binding:"required"`
|
||||||
|
WidthFirst float64 `json:"width_first" binding:"required"`
|
||||||
|
|
||||||
// Кромка
|
EdgeL1 string `json:"edge_l1"`
|
||||||
EdgeL1 string `json:"edge_l1"` // Обозначение кромки [L1]
|
EdgeL2 string `json:"edge_l2"`
|
||||||
EdgeL2 string `json:"edge_l2"` // Обозначение кромки [L2]
|
EdgeW1 string `json:"edge_w1"`
|
||||||
EdgeW1 string `json:"edge_w1"` // Обозначение кромки [W1]
|
EdgeW2 string `json:"edge_w2"`
|
||||||
EdgeW2 string `json:"edge_w2"` // Обозначение кромки [W2]
|
|
||||||
|
|
||||||
// Доп. инфо
|
Groove string `json:"groove"`
|
||||||
Groove string `json:"groove"` // Паз
|
Note string `json:"note"`
|
||||||
Note string `json:"note"` // Примечание
|
ProductName string `json:"product_name" binding:"required"`
|
||||||
ProductName string `json:"product_name" binding:"required"` // Наимен. изделия
|
|
||||||
|
|
||||||
Status string `json:"status" gorm:"default:Создан"`
|
Status string `json:"status" gorm:"default:Создан"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{js,jsx}'],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
reactHooks.configs.flat.recommended,
|
{
|
||||||
reactRefresh.configs.vite,
|
files: ["**/*.jsx", "**/*.tsx"],
|
||||||
],
|
plugins: {
|
||||||
|
react: pluginReact,
|
||||||
|
},
|
||||||
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
@@ -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>
|
||||||
|
|||||||
Generated
+2066
-89
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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
@@ -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;
|
|
||||||
|
|
||||||
|
|||||||
+47
-30
@@ -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),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
<p className="text-slate-400 text-sm mb-4">Выберите .csv файл для массовой загрузки</p>
|
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
|
||||||
onChange={handleUpload}
|
|
||||||
accept=".csv"
|
accept=".csv"
|
||||||
|
onChange={handleUpload}
|
||||||
className="hidden"
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
/>
|
||||||
|
<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>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<button
|
||||||
<table className="w-full text-left">
|
onClick={onRefresh}
|
||||||
<thead>
|
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"
|
||||||
<tr className="text-slate-500 text-[10px] uppercase border-b border-slate-700">
|
>
|
||||||
<th className="pb-4">Заказ / ID</th>
|
Обновить <span className="text-lg">↻</span>
|
||||||
<th className="pb-4">Деталь</th>
|
</button>
|
||||||
<th className="pb-4">Размеры (L x W)</th>
|
</div>
|
||||||
<th className="pb-4">Статус</th>
|
|
||||||
<th className="pb-4">Действие</th>
|
<div className="flex flex-col gap-6">
|
||||||
</tr>
|
{parts.map((p) => (
|
||||||
</thead>
|
<div
|
||||||
<tbody className="divide-y divide-slate-700/50">
|
key={p.id}
|
||||||
{parts.map(p => (
|
onClick={() => navigate(`/part/${p.id}`)}
|
||||||
<tr key={p.id} className="hover:bg-white/5 transition-colors">
|
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)]"
|
||||||
<td className="py-4">
|
>
|
||||||
<div className="font-mono text-blue-400 text-xs">{p.id}</div>
|
{/* Верхняя строка: ID и Номер заказа */}
|
||||||
<div className="text-[10px] text-slate-500">{p.order_no}</div>
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div className="text-[10px] text-slate-500">{p.destignation}</div>
|
<div className="flex items-center gap-3">
|
||||||
</td>
|
<span className="font-mono text-blue-500 text-sm font-black bg-blue-500/10 px-2 py-0.5 rounded">
|
||||||
<td className="py-4">
|
{p.id}
|
||||||
<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>
|
</span>
|
||||||
</td>
|
<div className="text-[13px] text-slate-300 font-bold tracking-tight">
|
||||||
<td className="py-4">
|
{p.order_no}
|
||||||
<button onClick={() => onDelete(p.id)} className="hover:scale-125 transition-transform text-red-500">
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(p.id);
|
||||||
|
}}
|
||||||
|
className="text-slate-600 hover:text-red-500 transition-colors p-1"
|
||||||
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
|
||||||
|
{/* Название и Материал */}
|
||||||
|
<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>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -20,12 +20,11 @@ const Scanner = ({ onScan, onClose }) => {
|
|||||||
{ 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;
|
||||||
|
|
||||||
|
|||||||
+12
-103
@@ -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);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
|
||||||
filter: invert(1) brightness(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
padding: 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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
html,
|
||||||
h2 {
|
body,
|
||||||
font-family: var(--heading);
|
#root {
|
||||||
font-weight: 500;
|
width: 100%;
|
||||||
color: var(--text-h);
|
min-height: 100vh;
|
||||||
}
|
background-color: #0f172a;
|
||||||
|
/* Твой темный фон */
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user