Enhance Button and CreateBuildModal components; add title prop to Button for accessibility, refactor CreateBuildModal to manage multiple builds selection, improve loading states, and streamline API interactions for better user experience.

This commit is contained in:
2026-03-16 18:59:49 +05:00
parent 1e22853146
commit 9f828473f6
16 changed files with 514 additions and 100 deletions
+3
View File
@@ -13,6 +13,7 @@ interface Props {
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
title?: string;
}
const variantClasses = {
@@ -58,11 +59,13 @@ function Button({
disabled,
loading,
onClick,
title,
}: Props) {
return (
<button
type={type}
disabled={disabled || loading}
title={title}
className={`flex items-center justify-center transition-all outline-none ${
variantClasses[variant]
} ${onlyIcon ? onlyIconSizeClasses[size] : sizeClasses[size]} ${
+109 -69
View File
@@ -1,37 +1,34 @@
import { FormEvent, useState } from "react";
import { useEffect, useState } from "react";
import Button from "../Button";
import Input from "../Input";
import api from "../../utils/api";
import IError from "../../types/IError";
import IBuild from "../../types/IBuild";
import useModalStore from "../../stores/useModalStore";
import Select3 from "../Select3";
import SpinnerIcon from "../icons/SpinnerIcon";
interface Props {
companyId: string;
}
function CreateBuildModal({ companyId }: Props) {
const [name, setName] = useState<string>("");
const [build, setBuild] = useState<string>("");
const [builds, setBuilds] = useState<IBuild[]>([]);
const [selectedBuilds, setSelectedBuilds] = useState<IBuild[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const { setModal } = useModalStore();
function handleSubmit(e: FormEvent) {
e.preventDefault();
addBuild();
function toggleBuild(build: IBuild) {
setSelectedBuilds((prev) =>
prev.some((b) => b.id === build.id)
? prev.filter((b) => b.id !== build.id)
: [...prev, build]
);
}
async function addBuild() {
async function fetchBuilds() {
try {
const result: IBuild | IError = await api
.post(`admin/builds`, {
json: {
companyId,
name,
build,
},
})
const result: IBuild[] | IError = await api
.get(`admin/builds?availableForCompany=${companyId}`)
.json();
if ("error" in result) {
@@ -39,65 +36,108 @@ function CreateBuildModal({ companyId }: Props) {
return;
}
window.location.reload();
setBuilds(result);
} catch (error) {
alert((error as Error).message);
} finally {
setIsLoading(false);
}
}
return (
<div className="p-8 space-y-4 bg-white rounded-xl">
<p className="text-xl font-semibold">Создание ЖК</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input
required
autoFocus
placeholder="Название ЖК"
className="w-64"
onChange={(value) => setName(value)}
/>
<Select3
required
options={[
"nksJukovaDev",
"Avgust",
"DNScity",
"DreamRiva",
"GalleryEkb",
"Ivazowsky",
"IzdanieBrusnika",
"Kama",
"lifeResidence",
"MasharovDev",
"MayakPruds",
"MirapolisDev",
"Myatnyi",
"novatorDev",
"orientDev",
"PortovayaDev",
"Prokshino",
"RockCityDev",
"SevernyPort",
"Sezar",
"ShipyardSaudiDev",
"StroyProject",
"StroyProject2",
"TyoplyeQuDev",
"upsideTowersDev",
"VoiceInHeart",
"WillTowers",
"ZolotoyRog",
"Zoolog",
]}
onSelect={(option) => setBuild(option)}
/>
<div className="flex self-end gap-2">
useEffect(() => {
fetchBuilds();
}, [companyId]);
async function handleAdd() {
if (selectedBuilds.length === 0) return;
setIsSubmitting(true);
try {
for (const build of selectedBuilds) {
const result: IBuild | IError = await api
.post("admin/builds", {
json: {
companyId,
buildId: build.id,
},
})
.json();
if ("error" in result) {
alert(result.error);
return;
}
}
setModal(null);
window.location.reload();
} catch (error) {
alert((error as Error).message);
} finally {
setIsSubmitting(false);
}
}
if (isLoading) {
return (
<div className="flex justify-center items-center p-12 bg-white rounded-lg w-[400px]">
<SpinnerIcon />
</div>
);
}
if (builds.length === 0) {
return (
<div className="p-8 space-y-4 bg-white rounded-lg w-[400px]">
<p className="text-xl font-semibold">Добавить ЖК</p>
<p className="text-gray-600">
В базе данных пока нет ЖК. Создайте ЖК через базу данных вручную.
</p>
<div className="flex self-end">
<Button variant="secondary" onClick={() => setModal(null)}>
Отмена
Закрыть
</Button>
<Button type="submit">Сохранить</Button>
</div>
</form>
</div>
);
}
return (
<div className="p-8 space-y-4 bg-white rounded-lg w-[400px]">
<p className="text-xl font-semibold">Выбор ЖК</p>
<p className="text-sm text-gray-600">
Выберите один или несколько ЖК для добавления в компанию
</p>
<div className="overflow-y-auto space-y-2 max-h-64">
{builds.map((build) => (
<button
key={build.id}
type="button"
onClick={() => toggleBuild(build)}
className={`w-full p-4 text-left rounded-lg border transition-colors ${
selectedBuilds.some((b) => b.id === build.id)
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
}`}
>
<p className="font-medium">{build.name}</p>
<p className="text-sm text-gray-500">Сборка: {build.build}</p>
</button>
))}
</div>
<div className="flex gap-2 self-end">
<Button variant="secondary" onClick={() => setModal(null)}>
Отмена
</Button>
<Button
onClick={handleAdd}
disabled={selectedBuilds.length === 0 || isSubmitting}
>
{isSubmitting
? "Добавление…"
: `Добавить${selectedBuilds.length > 0 ? ` (${selectedBuilds.length})` : ""}`}
</Button>
</div>
</div>
);
}
+36 -1
View File
@@ -13,6 +13,7 @@ import CreateUserModal from "../components/modals/CreateUserModal";
import { Transition } from "react-transition-group";
import SpinnerIcon from "../components/icons/SpinnerIcon";
import MoreIcon from "../components/icons/MoreIcon";
import CloseIcon from "../components/icons/CloseIcon";
import EditUserModal from "../components/modals/EditUserModal";
function AdminCompanyPage() {
@@ -61,6 +62,29 @@ function AdminCompanyPage() {
}
}
async function removeBuildFromCompany(buildId: string) {
if (
!companyId ||
!confirm("Убрать ЖК из компании? ЖК останется в каталоге.")
)
return;
try {
const result: { error?: string } | { success?: boolean } = await api
.delete(`admin/companies/${companyId}/builds/${buildId}`)
.json();
if ("error" in result) {
alert(result.error);
return;
}
getBuilds();
} catch (error) {
alert((error as Error).message);
}
}
useEffect(() => {
getBuilds();
getUsers();
@@ -100,7 +124,10 @@ function AdminCompanyPage() {
<div className="grid grid-cols-4 gap-4 pb-8 border-b border-gray-300">
{builds &&
builds.map((build) => (
<div className="overflow-hidden relative bg-white rounded-xl">
<div
key={build.id}
className="overflow-hidden relative bg-white rounded-xl group"
>
<img
src=""
alt=""
@@ -113,6 +140,14 @@ function AdminCompanyPage() {
<span className="font-semibold">{build.build}</span>
</p>
</div>
<Button
variant="tertiary"
icon={<CloseIcon className="w-5 h-5" />}
onlyIcon
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-white/90 hover:bg-white"
onClick={() => removeBuildFromCompany(build.id)}
title="Убрать из компании"
/>
</div>
))}
</div>
-1
View File
@@ -1,6 +1,5 @@
interface IBuild {
id: string;
companyId: string;
build: string;
name: string;
}
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "nodemon --exec node --import=./register.js ./src/index.ts",
"build": "npx tsc -p ./",
"start": "node ./dist/index.js"
"start": "node ./dist/index.js",
"migrate:builds": "node --import=./register.js ./scripts/migrate-builds-to-company-build.ts"
},
"dependencies": {
"bcrypt": "^5.1.1",
@@ -0,0 +1,67 @@
/**
* Миграция: создание связей CompanyBuild из старых Build с companyId
*
* Запуск: npx ts-node --esm scripts/migrate-builds-to-company-build.ts
* (из папки server, с загруженным .env)
*/
import "dotenv/config";
import mongoose from "mongoose";
import Build from "../src/models/Build.js";
import CompanyBuild from "../src/models/CompanyBuild.js";
async function migrate() {
await mongoose.connect(process.env.MONGO_URI!, { dbName: "crm_stream" });
console.log("MongoDB connected");
const db = mongoose.connection.db;
if (!db) throw new Error("No database connection");
const buildsCollection = db.collection("builds");
const buildsWithCompany = await buildsCollection
.find({ companyId: { $exists: true, $ne: null } })
.toArray();
console.log(`Найдено ${buildsWithCompany.length} сборок с companyId`);
let created = 0;
let skipped = 0;
for (const build of buildsWithCompany) {
const companyId = build.companyId;
const buildId = build._id;
const existing = await CompanyBuild.findOne({
companyId,
buildId,
});
if (existing) {
skipped++;
continue;
}
await CompanyBuild.create({
companyId: new mongoose.Types.ObjectId(companyId.toString()),
buildId: new mongoose.Types.ObjectId(buildId.toString()),
});
created++;
console.log(` Создана связь: company ${companyId} -> build ${buildId}`);
}
console.log(`\nГотово. Создано: ${created}, пропущено (уже есть): ${skipped}`);
// Удаляем companyId из документов Build (опционально, для консистентности)
const unsetResult = await buildsCollection.updateMany(
{ companyId: { $exists: true } },
{ $unset: { companyId: "" } }
);
console.log(`Удалено поле companyId из ${unsetResult.modifiedCount} документов Build`);
await mongoose.disconnect();
process.exit(0);
}
migrate().catch((err) => {
console.error(err);
process.exit(1);
});
+19 -3
View File
@@ -4,6 +4,7 @@ import connectDB from "./config/db.js";
import cors from "cors";
import cookieParser from "cookie-parser";
import authMiddleware from "./middlewares/auth.js";
import adminOnlyMiddleware from "./middlewares/adminOnly.js";
import registerRoute from "./routes/register.js";
import refreshRoute from "./routes/refresh.js";
import checkRoute from "./routes/check.js";
@@ -46,9 +47,24 @@ app.use("/resetConfirm", resetConfirmRoute);
app.use("/actions", actionsRouter);
app.use("/builds", buildsRouter);
app.use("/scheduledSessions", scheduledSessionsRoute);
app.use("/admin/companies", adminCompaniesRoute);
app.use("/admin/builds", adminBuildsRoute);
app.use("/admin/users", adminUsersRoute);
app.use(
"/admin/companies",
authMiddleware,
adminOnlyMiddleware,
adminCompaniesRoute
);
app.use(
"/admin/builds",
authMiddleware,
adminOnlyMiddleware,
adminBuildsRoute
);
app.use(
"/admin/users",
authMiddleware,
adminOnlyMiddleware,
adminUsersRoute
);
app.use("/companies", authMiddleware, companiesRouter);
app.use("/users", authMiddleware, usersRouter);
app.use("/changePassword", authMiddleware, changePasswordRoute);
+15
View File
@@ -0,0 +1,15 @@
import { Request, Response, NextFunction } from "express";
const ADMIN_USERNAME = "admin@graff.tech";
function adminOnlyMiddleware(req: Request, res: Response, next: NextFunction) {
const user = res.locals.user;
if (!user || user.username !== ADMIN_USERNAME) {
return res.status(403).json({ error: "Доступ запрещён" });
}
next();
}
export default adminOnlyMiddleware;
-4
View File
@@ -2,10 +2,6 @@ import { model, Schema } from "mongoose";
const buildSchema = new Schema(
{
companyId: {
type: Schema.Types.ObjectId,
required: true,
},
name: {
type: String,
required: true,
+2 -2
View File
@@ -46,8 +46,8 @@ companySchema.virtual("users", {
foreignField: "companyId",
});
companySchema.virtual("builds", {
ref: "Build",
companySchema.virtual("companyBuilds", {
ref: "CompanyBuild",
localField: "_id",
foreignField: "companyId",
});
+25
View File
@@ -0,0 +1,25 @@
import { model, Schema } from "mongoose";
const companyBuildSchema = new Schema(
{
companyId: {
type: Schema.Types.ObjectId,
ref: "Company",
required: true,
},
buildId: {
type: Schema.Types.ObjectId,
ref: "Build",
required: true,
},
},
{
timestamps: true,
}
);
companyBuildSchema.index({ companyId: 1, buildId: 1 }, { unique: true });
const CompanyBuild = model("CompanyBuild", companyBuildSchema);
export default CompanyBuild;
+75 -3
View File
@@ -1,12 +1,45 @@
import { Router } from "express";
import Build from "../../models/Build.js";
import CompanyBuild from "../../models/CompanyBuild.js";
import { isValidObjectId, sanitizeAdminQuery } from "../../utils/adminUtils.js";
const router = Router();
const BUILD_FIELDS = ["name", "build"];
router.get("/", async (req, res) => {
try {
const result = await Build.find(req.query);
const query = req.query as Record<string, unknown>;
const companyId = query.companyId as string | undefined;
const availableForCompany = query.availableForCompany as string | undefined;
if (availableForCompany) {
// Список сборок из каталога, которые ещё не привязаны к компании
const linked = await CompanyBuild.find({
companyId: availableForCompany,
}).distinct("buildId");
const result = await Build.find({ _id: { $nin: linked } });
return res.json(result);
}
if (companyId) {
// Сборки, привязанные к компании (через CompanyBuild)
const companyBuilds = await CompanyBuild.find({ companyId })
.populate("buildId")
.lean();
const builds = companyBuilds
.map((cb) => cb.buildId)
.filter(Boolean)
.map((b) => {
const doc = b as unknown as { _id?: { toString: () => string }; id?: string };
return { ...doc, id: doc.id ?? (doc._id ? doc._id.toString() : undefined) };
});
return res.json(builds);
}
// Все сборки из каталога
const filter = sanitizeAdminQuery(query, "builds");
const result = await Build.find(filter);
res.json(result);
} catch (error) {
res.json({ error: (error as Error).message });
@@ -15,6 +48,10 @@ router.get("/", async (req, res) => {
router.get("/:id", async (req, res) => {
try {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const result = await Build.findById(req.params.id);
res.json(result);
@@ -25,7 +62,27 @@ router.get("/:id", async (req, res) => {
router.post("/", async (req, res) => {
try {
const result = await Build.create(req.body);
const { companyId, buildId } = req.body;
if (companyId && buildId) {
// Создание связи компания-сборка (many-to-many)
if (!isValidObjectId(companyId) || !isValidObjectId(buildId)) {
return res.status(400).json({ error: "Некорректный ID компании или сборки" });
}
await CompanyBuild.create({ companyId, buildId });
const build = await Build.findById(buildId);
return res.json(build);
}
// Создание новой сборки в каталоге
const body = Object.fromEntries(
BUILD_FIELDS.filter((k) => req.body[k] !== undefined).map((k) => [
k,
req.body[k],
])
);
const result = await Build.create(body);
res.json(result);
} catch (error) {
@@ -35,7 +92,18 @@ router.post("/", async (req, res) => {
router.put("/:id", async (req, res) => {
try {
const result = await Build.findByIdAndUpdate(req.params.id, req.body, {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const body = Object.fromEntries(
BUILD_FIELDS.filter((k) => req.body[k] !== undefined).map((k) => [
k,
req.body[k],
])
);
const result = await Build.findByIdAndUpdate(req.params.id, body, {
new: true,
});
@@ -47,6 +115,10 @@ router.put("/:id", async (req, res) => {
router.delete("/:id", async (req, res) => {
try {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const result = await Build.findByIdAndRemove(req.params.id);
res.json(result);
+67 -3
View File
@@ -1,11 +1,26 @@
import { Router } from "express";
import Company from "../../models/Company.js";
import CompanyBuild from "../../models/CompanyBuild.js";
import { isValidObjectId, sanitizeAdminQuery } from "../../utils/adminUtils.js";
const router = Router();
const COMPANY_FIELDS = [
"name",
"sessionLimit",
"avatar",
"phone",
"site",
"email",
"address",
"startTime",
"endTime",
];
router.get("/", async (req, res) => {
try {
const result = await Company.find(req.query);
const filter = sanitizeAdminQuery(req.query as Record<string, unknown>, "companies");
const result = await Company.find(filter);
res.json(result);
} catch (error) {
@@ -15,6 +30,10 @@ router.get("/", async (req, res) => {
router.get("/:id", async (req, res) => {
try {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const result = await Company.findById(req.params.id);
res.json(result);
@@ -25,7 +44,14 @@ router.get("/:id", async (req, res) => {
router.post("/", async (req, res) => {
try {
const result = await Company.create(req.body);
const body = Object.fromEntries(
COMPANY_FIELDS.filter((k) => req.body[k] !== undefined).map((k) => [
k,
req.body[k],
])
);
const result = await Company.create(body);
res.json(result);
} catch (error) {
@@ -35,7 +61,18 @@ router.post("/", async (req, res) => {
router.put("/:id", async (req, res) => {
try {
const result = await Company.findByIdAndUpdate(req.params.id, req.body, {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const body = Object.fromEntries(
COMPANY_FIELDS.filter((k) => req.body[k] !== undefined).map((k) => [
k,
req.body[k],
])
);
const result = await Company.findByIdAndUpdate(req.params.id, body, {
new: true,
});
@@ -45,8 +82,35 @@ router.put("/:id", async (req, res) => {
}
});
router.delete("/:companyId/builds/:buildId", async (req, res) => {
try {
const { companyId, buildId } = req.params;
if (!isValidObjectId(companyId) || !isValidObjectId(buildId)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const result = await CompanyBuild.findOneAndDelete({
companyId,
buildId,
});
if (!result) {
return res.status(404).json({ error: "Связь не найдена" });
}
res.json({ success: true });
} catch (error) {
res.json({ error: (error as Error).message });
}
});
router.delete("/:id", async (req, res) => {
try {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const result = await Company.findByIdAndRemove(req.params.id);
res.json(result);
+51 -7
View File
@@ -1,12 +1,23 @@
import bcrypt from "bcrypt";
import { Router } from "express";
import User from "../../models/User.js";
import { isValidObjectId, sanitizeAdminQuery } from "../../utils/adminUtils.js";
const router = Router();
const USER_FIELDS = ["username", "companyId", "role", "name", "buildIds"];
function toSafeUser(doc: { toJSON?: () => Record<string, unknown> } | null) {
if (!doc) return null;
const json = doc.toJSON ? doc.toJSON() : (doc as Record<string, unknown>);
const { password, resetCode, ...safe } = json as Record<string, unknown>;
return safe;
}
router.get("/", async (req, res) => {
try {
const result = await User.find(req.query);
const filter = sanitizeAdminQuery(req.query as Record<string, unknown>, "users");
const result = await User.find(filter).select("-password -resetCode");
res.json(result);
} catch (error) {
@@ -16,7 +27,11 @@ router.get("/", async (req, res) => {
router.get("/:id", async (req, res) => {
try {
const result = await User.findById(req.params.id);
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const result = await User.findById(req.params.id).select("-password -resetCode");
res.json(result);
} catch (error) {
@@ -26,10 +41,20 @@ router.get("/:id", async (req, res) => {
router.post("/", async (req, res) => {
try {
const passwordHash = bcrypt.hashSync(req.body.password, 12);
const result = await User.create({ ...req.body, password: passwordHash });
if (!req.body.password || typeof req.body.password !== "string") {
return res.status(400).json({ error: "Пароль обязателен" });
}
res.json(result);
const passwordHash = bcrypt.hashSync(req.body.password, 12);
const body = Object.fromEntries(
[...USER_FIELDS, "password"]
.filter((k) => req.body[k] !== undefined)
.map((k) => [k, k === "password" ? passwordHash : req.body[k]])
);
const result = await User.create(body);
res.json(toSafeUser(result));
} catch (error) {
res.json({ error: (error as Error).message });
}
@@ -37,11 +62,26 @@ router.post("/", async (req, res) => {
router.put("/:id", async (req, res) => {
try {
const result = await User.findByIdAndUpdate(req.params.id, req.body, {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const update: Record<string, unknown> = Object.fromEntries(
USER_FIELDS.filter((k) => req.body[k] !== undefined).map((k) => [
k,
req.body[k],
])
);
if (req.body.password !== undefined && req.body.password !== "") {
update.password = bcrypt.hashSync(req.body.password, 12);
}
const result = await User.findByIdAndUpdate(req.params.id, update, {
new: true,
});
res.json(result);
res.json(toSafeUser(result));
} catch (error) {
res.json({ error: (error as Error).message });
}
@@ -49,6 +89,10 @@ router.put("/:id", async (req, res) => {
router.delete("/:id", async (req, res) => {
try {
if (!isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Некорректный ID" });
}
const result = await User.findByIdAndRemove(req.params.id);
res.json(result);
+15 -6
View File
@@ -1,8 +1,8 @@
import { Router } from "express";
import { parseISO, startOfDay, endOfDay } from "date-fns";
import { parseISO } from "date-fns";
import Company from "../models/Company.js";
import CompanyBuild from "../models/CompanyBuild.js";
import ScheduledSession from "../models/ScheduledSession.js";
import Schedule from "../models/Schedule.js";
import User from "../models/User.js";
import mongoose from "mongoose";
@@ -45,10 +45,19 @@ router.get("/:companyId/builds", async (req, res) => {
return res.status(403).json({ error: "Access denied" });
}
const company: any = await Company.findById(req.params.companyId).populate(
"builds"
);
const { builds } = company;
const companyBuilds = await CompanyBuild.find({
companyId: req.params.companyId,
})
.populate("buildId")
.lean();
const builds = companyBuilds
.map((cb) => cb.buildId)
.filter(Boolean)
.map((b) => {
const doc = b as unknown as { _id?: { toString: () => string }; id?: string };
return { ...doc, id: doc.id ?? (doc._id ? doc._id.toString() : undefined) };
});
res.json(builds);
});
+28
View File
@@ -0,0 +1,28 @@
import mongoose from "mongoose";
const ALLOWED_QUERY_KEYS: Record<string, string[]> = {
companies: [],
builds: ["companyId", "availableForCompany"],
users: ["companyId"],
};
export function sanitizeAdminQuery(
query: Record<string, unknown>,
route: "companies" | "builds" | "users"
): Record<string, unknown> {
const allowed = ALLOWED_QUERY_KEYS[route];
const sanitized: Record<string, unknown> = {};
for (const key of allowed) {
const value = query[key];
if (value !== undefined && value !== null && value !== "") {
sanitized[key] = value;
}
}
return sanitized;
}
export function isValidObjectId(id: string): boolean {
return mongoose.Types.ObjectId.isValid(id) && String(new mongoose.Types.ObjectId(id)) === id;
}