Merge branch 'unstable' into 'main'

Unstable

See merge request internship-2025/survey-webapp/survey-webapp!21
This commit is contained in:
Tatyana Nikolaeva 2025-05-26 07:53:11 +00:00
commit 66350ff918
43 changed files with 1814 additions and 363 deletions

View file

@ -24,5 +24,11 @@ public class ApplicationDbContext : SurveyDbContext
.WithMany()
.HasForeignKey(s => s.CreatedBy)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<Completion>()
.HasOne<User>()
.WithMany()
.HasForeignKey(c => c.CompletedBy)
.OnDelete(DeleteBehavior.SetNull);
}
}

View file

@ -0,0 +1,321 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using SurveyBackend.Infrastructure.Data;
#nullable disable
namespace SurveyBackend.Infrastructure.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250525200829_Completion update of SurveyLib")]
partial class CompletionupdateofSurveyLib
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.15");
modelBuilder.Entity("GroupUser", b =>
{
b.Property<int>("GroupsId")
.HasColumnType("INTEGER");
b.Property<int>("UsersId")
.HasColumnType("INTEGER");
b.HasKey("GroupsId", "UsersId");
b.HasIndex("UsersId");
b.ToTable("GroupUser");
});
modelBuilder.Entity("SurveyBackend.Core.Models.Group", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Label")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Groups");
});
modelBuilder.Entity("SurveyBackend.Core.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("LastName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("SurveyLib.Core.Models.Answer", b =>
{
b.Property<int>("CompletionId")
.HasColumnType("INTEGER");
b.Property<int>("QuestionId")
.HasColumnType("INTEGER");
b.Property<string>("AnswerText")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("CompletionId", "QuestionId");
b.HasIndex("QuestionId");
b.ToTable("Answers");
});
modelBuilder.Entity("SurveyLib.Core.Models.AnswerVariant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("QuestionId")
.HasColumnType("INTEGER");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("QuestionId");
b.ToTable("AnswerVariants");
});
modelBuilder.Entity("SurveyLib.Core.Models.Completion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("CompletedBy")
.HasColumnType("INTEGER");
b.Property<DateTime>("FinishedAt")
.HasColumnType("TEXT");
b.Property<int>("SurveyId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CompletedBy");
b.HasIndex("SurveyId");
b.ToTable("Completions");
});
modelBuilder.Entity("SurveyLib.Core.Models.QuestionBase", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(34)
.HasColumnType("TEXT");
b.Property<int>("SurveyId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SurveyId");
b.ToTable("Questions");
b.HasDiscriminator().HasValue("QuestionBase");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("SurveyLib.Core.Models.Survey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatedBy")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Surveys");
});
modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.MultipleAnswerQuestion", b =>
{
b.HasBaseType("SurveyLib.Core.Models.QuestionBase");
b.HasDiscriminator().HasValue("MultipleAnswerQuestion");
});
modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.SingleAnswerQuestion", b =>
{
b.HasBaseType("SurveyLib.Core.Models.QuestionBase");
b.HasDiscriminator().HasValue("SingleAnswerQuestion");
});
modelBuilder.Entity("SurveyLib.Core.Models.QuestionVariants.TextQuestion", b =>
{
b.HasBaseType("SurveyLib.Core.Models.QuestionBase");
b.HasDiscriminator().HasValue("TextQuestion");
});
modelBuilder.Entity("GroupUser", b =>
{
b.HasOne("SurveyBackend.Core.Models.Group", null)
.WithMany()
.HasForeignKey("GroupsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SurveyBackend.Core.Models.User", null)
.WithMany()
.HasForeignKey("UsersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("SurveyLib.Core.Models.Answer", b =>
{
b.HasOne("SurveyLib.Core.Models.Completion", "Completion")
.WithMany("Answers")
.HasForeignKey("CompletionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SurveyLib.Core.Models.QuestionBase", "Question")
.WithMany("Answers")
.HasForeignKey("QuestionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Completion");
b.Navigation("Question");
});
modelBuilder.Entity("SurveyLib.Core.Models.AnswerVariant", b =>
{
b.HasOne("SurveyLib.Core.Models.QuestionBase", "Question")
.WithMany("AnswerVariants")
.HasForeignKey("QuestionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Question");
});
modelBuilder.Entity("SurveyLib.Core.Models.Completion", b =>
{
b.HasOne("SurveyBackend.Core.Models.User", null)
.WithMany()
.HasForeignKey("CompletedBy")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("SurveyLib.Core.Models.Survey", "Survey")
.WithMany("Completions")
.HasForeignKey("SurveyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Survey");
});
modelBuilder.Entity("SurveyLib.Core.Models.QuestionBase", b =>
{
b.HasOne("SurveyLib.Core.Models.Survey", "Survey")
.WithMany("Questions")
.HasForeignKey("SurveyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Survey");
});
modelBuilder.Entity("SurveyLib.Core.Models.Survey", b =>
{
b.HasOne("SurveyBackend.Core.Models.User", null)
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("SurveyLib.Core.Models.Completion", b =>
{
b.Navigation("Answers");
});
modelBuilder.Entity("SurveyLib.Core.Models.QuestionBase", b =>
{
b.Navigation("AnswerVariants");
b.Navigation("Answers");
});
modelBuilder.Entity("SurveyLib.Core.Models.Survey", b =>
{
b.Navigation("Completions");
b.Navigation("Questions");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SurveyBackend.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class CompletionupdateofSurveyLib : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "CompletedBy",
table: "Completions",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Completions_CompletedBy",
table: "Completions",
column: "CompletedBy");
migrationBuilder.AddForeignKey(
name: "FK_Completions_Users_CompletedBy",
table: "Completions",
column: "CompletedBy",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Completions_Users_CompletedBy",
table: "Completions");
migrationBuilder.DropIndex(
name: "IX_Completions_CompletedBy",
table: "Completions");
migrationBuilder.DropColumn(
name: "CompletedBy",
table: "Completions");
}
}
}

View file

@ -119,6 +119,9 @@ namespace SurveyBackend.Infrastructure.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("CompletedBy")
.HasColumnType("INTEGER");
b.Property<DateTime>("FinishedAt")
.HasColumnType("TEXT");
@ -127,6 +130,8 @@ namespace SurveyBackend.Infrastructure.Data.Migrations
b.HasKey("Id");
b.HasIndex("CompletedBy");
b.HasIndex("SurveyId");
b.ToTable("Completions");
@ -256,6 +261,11 @@ namespace SurveyBackend.Infrastructure.Data.Migrations
modelBuilder.Entity("SurveyLib.Core.Models.Completion", b =>
{
b.HasOne("SurveyBackend.Core.Models.User", null)
.WithMany()
.HasForeignKey("CompletedBy")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("SurveyLib.Core.Models.Survey", "Survey")
.WithMany("Completions")
.HasForeignKey("SurveyId")

View file

@ -9,9 +9,13 @@
"version": "0.0.0",
"dependencies": {
"@formkit/tempo": "^0.1.2",
"chart.js": "^4.4.9",
"chartjs-plugin-annotation": "^3.1.0",
"chartjs-plugin-datalabels": "^2.2.0",
"mobx": "^6.13.7",
"mobx-react": "^9.2.0",
"react": "^19.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.2",
"react-textarea-autosize": "^8.5.9",
@ -972,6 +976,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -1989,6 +1999,36 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chart.js": {
"version": "4.4.9",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz",
"integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chartjs-plugin-annotation": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=4.0.0"
}
},
"node_modules/chartjs-plugin-datalabels": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
"integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=3.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -3102,6 +3142,16 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chartjs-2": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
"integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",

View file

@ -11,9 +11,13 @@
},
"dependencies": {
"@formkit/tempo": "^0.1.2",
"chart.js": "^4.4.9",
"chartjs-plugin-annotation": "^3.1.0",
"chartjs-plugin-datalabels": "^2.2.0",
"mobx": "^6.13.7",
"mobx-react": "^9.2.0",
"react": "^19.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.2",
"react-textarea-autosize": "^8.5.9",

View file

@ -8,6 +8,8 @@ import {Results} from "./components/Results/Results.tsx";
import {MySurveyList} from "./components/MySurveyList/MySurveyList.tsx";
import AuthForm from "./pages/AuthForm/AuthForm.tsx";
import {SurveyPage} from "./components/SurveyPage/SurveyPage.tsx";
import CompleteSurvey from "./pages/CompleteSurvey/CompleteSurvey.tsx";
import CompletingSurvey from "./components/CompletingSurvey/CompletingSurvey.tsx";
const App = () => {
return(
@ -31,6 +33,10 @@ const App = () => {
<Route path="results" element={<Results />} />
</Route>
<Route path='/complete-survey' element={<CompleteSurvey/>}>
<Route index element={<CompletingSurvey/>}/>
</Route>
<Route path="*" element={<AuthForm />} />
</Routes>
</BrowserRouter>

View file

@ -0,0 +1,94 @@
import {BASE_URL, createRequestConfig, handleResponse} from "./BaseApi.ts";
export interface INewAnswer{
text: string;
}
export interface IAnswerVariant extends INewAnswer{
surveyId: number;
id: number;
questionId: number;
}
export const getAnswerVariants = async (surveyId: number, questionId: number) => {
try{
const response = await fetch(`${BASE_URL}/surveys/${surveyId}/questions/${questionId}/answerVariants`, {
...createRequestConfig('GET')
})
return await handleResponse(response)
}catch(err){
console.error(`Error receiving response options: ${err}`);
throw err;
}
}
export const addNewAnswerVariant = async (surveyId: number, questionId: number, answer: INewAnswer) => {
const token = localStorage.getItem("token");
if (!token) {
throw new Error("Токен отсутствует");
}
try{
const response = await fetch(`${BASE_URL}/surveys/${surveyId}/questions/${questionId}/answerVariants`, {
...createRequestConfig('POST'),
body: JSON.stringify({
text: answer.text,
}),
})
if (!response.ok) {
throw new Error(`Ошибка: ${response.status}`);
}
return await handleResponse(response)
}
catch(err){
console.error(`Error adding a new response option: ${err}`);
throw err;
}
}
export const updateAnswerVariant = async (surveyId: number, questionId: number, id: number, answer: INewAnswer): Promise<INewAnswer> => {
const token = localStorage.getItem("token");
if (!token) {
throw new Error("Токен отсутствует");
}
try{
const response = await fetch(`${BASE_URL}/surveys/${surveyId}/questions/${questionId}/answerVariants/${id}`, {
...createRequestConfig('PUT'),
body: JSON.stringify({
text: answer.text,
})
})
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(`Ошибка ${response.status}: ${errorData?.message || 'Неизвестная ошибка'}`);
}
return await handleResponse(response)
}
catch(err){
console.error(`Error updating the response option: ${err}`);
throw err;
}
}
export const deleteAnswerVariant = async (surveyId: number, questionId: number, id: number) => {
const token = localStorage.getItem("token");
if (!token) {
throw new Error('Токен отсутствует');
}
try{
const response = await fetch(`${BASE_URL}/surveys/${surveyId}/questions/${questionId}/answerVariants/${id}`, {
...createRequestConfig('DELETE'),
})
const responseData = await handleResponse(response);
if (response.ok && !responseData){
return {success: true};
}
return responseData;
}
catch(err){
console.error(`Error deleting a answer: ${err}`);
throw err;
}
}

View file

@ -10,16 +10,10 @@ interface IRegistrationData extends IAuthData{
firstName: string;
lastName: string;
}
//
// interface IUserData{
// username: string;
// firstName: string;
// lastName: string;
// email: string;
// }
export const getCurrentUser = async (): Promise<IRegistrationData> => {
export const getCurrentUser = async () => {
const token = localStorage.getItem("token");
if (!token) {
throw new Error("Токен отсутствует");
}
@ -32,6 +26,8 @@ export const getCurrentUser = async (): Promise<IRegistrationData> => {
}
});
console.log(response);
if (response.status === 401) {
localStorage.removeItem("token");
throw new Error("Сессия истекла. Пожалуйста, войдите снова.");

View file

@ -20,12 +20,10 @@ const createRequestConfig = (method: string, isFormData: boolean = false): Reque
headers: {},
};
// Добавляем заголовок авторизации, если есть токен
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Добавляем Content-Type, если это не FormData
if (!isFormData) {
config.headers["Content-Type"] = "application/json";
}
@ -39,12 +37,16 @@ const createRequestConfig = (method: string, isFormData: boolean = false): Reque
* @returns Распарсенные данные или ошибку
*/
const handleResponse = async (response: Response) => {
// Проверяем, есть ли контент в ответе
const responseText = await response.text();
if (!responseText) {
if (response.status === 401) {
window.location.href = '/auth/login';
throw new Error('Требуется авторизация');
}
if (response.ok) {
return null; // Если ответ пустой, но статус 200, возвращаем null
return null;
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

View file

@ -1,4 +1,5 @@
import {BASE_URL, createRequestConfig, handleResponse} from "./BaseApi.ts";
import {IAnswerVariant} from "./AnswerApi.ts";
export interface INewQuestion{
title: string;
@ -8,11 +9,7 @@ export interface INewQuestion{
export interface IQuestion extends INewQuestion {
id: number;
surveyId: number;
answerVariants: Array<{
id: number;
questionId: number;
text: string;
}>
answerVariants: IAnswerVariant[];
}
export const addNewQuestion = async (surveyId: number, question: INewQuestion) => {

View file

@ -5,6 +5,7 @@ export interface ISurvey {
title: string;
description: string;
createdBy: number;
createdAt: string;
}
export interface INewSurvey{
@ -51,30 +52,6 @@ export const getAllSurveys = async (): Promise<ISurvey[]> => {
* postNewSurvey - добавление нового опроса
* @param survey
*/
// export const postNewSurvey = async (survey: INewSurvey): Promise<ISurvey> => {
// const token = localStorage.getItem("token");
// if (!token) {
// throw new Error("Токен отсутствует");
// }
//
// try{
// const response = await fetch(`${BASE_URL}/surveys`, {
// ...createRequestConfig('POST'),
// body: JSON.stringify(survey)
// })
//
// // return await handleResponse(response);
//
// if (response.status === 200) {
// return await handleResponse(response);
// }
// throw new Error(`Ожидался код 200, получен ${response.status}`);
// } catch (error) {
// console.error(`Error when adding a new survey: ${error}`);
// throw error;
// }
// }
export const postNewSurvey = async (survey: INewSurvey): Promise<ISurvey> => {
const token = localStorage.getItem("token");
if (!token) {

View file

@ -0,0 +1,3 @@
<svg width="58" height="61" viewBox="0 0 58 61" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 50.8333V43.7802C0 42.3399 0.342361 41.0055 1.02708 39.777C1.71181 38.5486 2.65833 37.6166 3.86667 36.9812C4.43056 36.6847 4.97431 36.4093 5.49792 36.1552C6.06181 35.901 6.64583 35.668 7.25 35.4562V50.8333H0ZM9.66667 33.0416C7.65278 33.0416 5.94097 32.3003 4.53125 30.8177C3.12153 29.335 2.41667 27.5347 2.41667 25.4166C2.41667 23.2986 3.12153 21.4982 4.53125 20.0156C5.94097 18.5329 7.65278 17.7916 9.66667 17.7916C11.6806 17.7916 13.3924 18.5329 14.8021 20.0156C16.2118 21.4982 16.9167 23.2986 16.9167 25.4166C16.9167 27.5347 16.2118 29.335 14.8021 30.8177C13.3924 32.3003 11.6806 33.0416 9.66667 33.0416ZM9.66667 27.9583C10.3514 27.9583 10.9153 27.7253 11.3583 27.2593C11.8417 26.751 12.0833 26.1368 12.0833 25.4166C12.0833 24.6965 11.8417 24.1034 11.3583 23.6375C10.9153 23.1291 10.3514 22.875 9.66667 22.875C8.98195 22.875 8.39792 23.1291 7.91458 23.6375C7.47153 24.1034 7.25 24.6965 7.25 25.4166C7.25 26.1368 7.47153 26.751 7.91458 27.2593C8.39792 27.7253 8.98195 27.9583 9.66667 27.9583ZM9.66667 50.8333V43.7166C9.66667 42.2763 10.009 40.9632 10.6938 39.777C11.4188 38.5486 12.3653 37.6166 13.5333 36.9812C16.0306 35.668 18.5681 34.6937 21.1458 34.0583C23.7236 33.3805 26.3417 33.0416 29 33.0416C31.6583 33.0416 34.2764 33.3805 36.8542 34.0583C39.4319 34.6937 41.9694 35.668 44.4667 36.9812C45.6347 37.6166 46.5611 38.5486 47.2458 39.777C47.9708 40.9632 48.3333 42.2763 48.3333 43.7166V50.8333H9.66667ZM14.5 45.75H43.5V43.7166C43.5 43.2507 43.3792 42.827 43.1375 42.4458C42.9361 42.0645 42.6542 41.768 42.2917 41.5562C40.1167 40.4125 37.9215 39.5652 35.7063 39.0145C33.491 38.4215 31.2556 38.125 29 38.125C26.7444 38.125 24.509 38.4215 22.2938 39.0145C20.0785 39.5652 17.8833 40.4125 15.7083 41.5562C15.3458 41.768 15.0437 42.0645 14.8021 42.4458C14.6007 42.827 14.5 43.2507 14.5 43.7166V45.75ZM29 30.5C26.3417 30.5 24.066 29.5045 22.1729 27.5135C20.2799 25.5225 19.3333 23.1291 19.3333 20.3333C19.3333 17.5375 20.2799 15.1441 22.1729 13.1531C24.066 11.1621 26.3417 10.1666 29 10.1666C31.6583 10.1666 33.934 11.1621 35.8271 13.1531C37.7201 15.1441 38.6667 17.5375 38.6667 20.3333C38.6667 23.1291 37.7201 25.5225 35.8271 27.5135C33.934 29.5045 31.6583 30.5 29 30.5ZM29 25.4166C30.3292 25.4166 31.4569 24.9295 32.3833 23.9552C33.35 22.9385 33.8333 21.7312 33.8333 20.3333C33.8333 18.9354 33.35 17.7493 32.3833 16.775C31.4569 15.7583 30.3292 15.25 29 15.25C27.6708 15.25 26.5229 15.7583 25.5563 16.775C24.6299 17.7493 24.1667 18.9354 24.1667 20.3333C24.1667 21.7312 24.6299 22.9385 25.5563 23.9552C26.5229 24.9295 27.6708 25.4166 29 25.4166ZM48.3333 33.0416C46.3194 33.0416 44.6076 32.3003 43.1979 30.8177C41.7882 29.335 41.0833 27.5347 41.0833 25.4166C41.0833 23.2986 41.7882 21.4982 43.1979 20.0156C44.6076 18.5329 46.3194 17.7916 48.3333 17.7916C50.3472 17.7916 52.059 18.5329 53.4688 20.0156C54.8785 21.4982 55.5833 23.2986 55.5833 25.4166C55.5833 27.5347 54.8785 29.335 53.4688 30.8177C52.059 32.3003 50.3472 33.0416 48.3333 33.0416ZM48.3333 27.9583C49.0181 27.9583 49.5819 27.7253 50.025 27.2593C50.5083 26.751 50.75 26.1368 50.75 25.4166C50.75 24.6965 50.5083 24.1034 50.025 23.6375C49.5819 23.1291 49.0181 22.875 48.3333 22.875C47.6486 22.875 47.0646 23.1291 46.5813 23.6375C46.1382 24.1034 45.9167 24.6965 45.9167 25.4166C45.9167 26.1368 46.1382 26.751 46.5813 27.2593C47.0646 27.7253 47.6486 27.9583 48.3333 27.9583ZM50.75 50.8333V35.4562C51.3542 35.668 51.9181 35.901 52.4417 36.1552C53.0056 36.4093 53.5695 36.6847 54.1333 36.9812C55.3417 37.6166 56.2882 38.5486 56.9729 39.777C57.6576 41.0055 58 42.3399 58 43.7802V50.8333H50.75Z" fill="#FEF7FF"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -0,0 +1,3 @@
<svg width="58" height="58" viewBox="0 0 58 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.25 48.3333V9.66663L53.1667 29L7.25 48.3333ZM12.0833 41.0833L40.7208 29L12.0833 16.9166V25.375L26.5833 29L12.0833 32.625V41.0833ZM12.0833 41.0833V29V16.9166V25.375V32.625V41.0833Z" fill="#FEF7FF"/>
</svg>

After

Width:  |  Height:  |  Size: 312 B

View file

@ -3,17 +3,22 @@ import styles from'./AnswerOption.module.css';
import Delete from '../../assets/delete.svg?react';
import Single from '../../assets/radio_button_unchecked.svg?react';
import Multiple from '../../assets/emptyCheckbox.svg?react';
import SelectedSingle from '../../assets/radio_button_checked.svg?react'
import SelectedMultiple from '../../assets/check_box.svg?react';
import TextareaAutosize from 'react-textarea-autosize';
interface AnswerOptionProps{
index: number;
value: string;
onChange: (value: string) => void;
onDelete:(index: number) => void;
selectedType: 'single' | 'multiply';
toggleSelect: () => void;
onChange?: (value: string) => void;
onDelete?:(index: number) => void;
selectedType: 'SingleAnswerQuestion' | 'MultipleAnswerQuestion';
isSelected?: boolean;
toggleSelect?: () => void;
isCompleteSurveyActive?: boolean;
}
const AnswerOption: React.FC<AnswerOptionProps> = ({index, value, onChange, onDelete, selectedType, toggleSelect}) => {
const AnswerOption: React.FC<AnswerOptionProps> = ({index, value, onChange, onDelete, selectedType, isSelected, toggleSelect, isCompleteSurveyActive = false}) => {
const [currentValue, setCurrentValue] = useState(value);
const [isEditing, setIsEditing] = useState(false);
@ -29,7 +34,6 @@ const AnswerOption: React.FC<AnswerOptionProps> = ({index, value, onChange, onDe
const handleTextareaChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setCurrentValue(event.target.value);
// Автоматическое изменение высоты
if (textAreaRef.current) {
textAreaRef.current.style.height = 'auto';
textAreaRef.current.style.height = `${textAreaRef.current.scrollHeight}px`;
@ -39,7 +43,6 @@ const AnswerOption: React.FC<AnswerOptionProps> = ({index, value, onChange, onDe
useEffect(() => {
if (isEditing && textAreaRef.current) {
textAreaRef.current.focus();
// Установка начальной высоты
textAreaRef.current.style.height = 'auto';
textAreaRef.current.style.height = `${textAreaRef.current.scrollHeight}px`;
}
@ -47,7 +50,7 @@ const AnswerOption: React.FC<AnswerOptionProps> = ({index, value, onChange, onDe
const handleSave = () => {
setIsEditing(false);
onChange(currentValue);
onChange?.(currentValue);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
@ -67,16 +70,50 @@ const AnswerOption: React.FC<AnswerOptionProps> = ({index, value, onChange, onDe
}
}, [isEditing]);
const handleMarkerClick = () => {
if (isCompleteSurveyActive && toggleSelect) {
toggleSelect();
}
};
return (
<div className={styles.answer}>
<button
className={`${styles.buttonMarker} ${isEditing ? styles.editing : ''}`}
onClick={toggleSelect}
>
{selectedType === 'single' ? < Single className={styles.answerIcon} /> : <Multiple className={styles.answerIcon} />}
</button>
{isEditing ? (
<textarea
{isCompleteSurveyActive ? (
<button
className={`${styles.buttonMarker} ${isSelected ? styles.selected : ''}`}
onClick={handleMarkerClick}
>
{selectedType === 'SingleAnswerQuestion' ? (
isSelected ? (
<SelectedSingle className={styles.answerIcon} />
) : (
<Single className={styles.answerIcon} />
)
) : (
isSelected ? (
<SelectedMultiple className={styles.answerIcon} />
) : (
<Multiple className={styles.answerIcon} />
)
)}
</button>
) : (
<button className={styles.buttonMarker}>
{selectedType === 'SingleAnswerQuestion' ? (
<Single className={styles.answerIcon} />
) : (
<Multiple className={styles.answerIcon} />
)}
</button>
)}
{isCompleteSurveyActive ? (
<button className={styles.textAnswer}>
{currentValue || `Ответ ${index}`}
</button>
) : isEditing ? (
<TextareaAutosize
className={styles.answerInput}
ref={textAreaRef}
value={currentValue}
@ -90,12 +127,14 @@ const AnswerOption: React.FC<AnswerOptionProps> = ({index, value, onChange, onDe
{currentValue || `Ответ ${index}`}
</button>
)}
<button className={styles.deleteButton} onClick={() => onDelete(index)}>
<Delete />
</button>
{!isCompleteSurveyActive && (
<button className={styles.deleteButton} onClick={() => onDelete?.(index)}>
<Delete />
</button>
)}
</div>
);
};
export default AnswerOption;

View file

@ -0,0 +1,7 @@
.survey{
width: 68%;
background-color: #F6F6F6;
max-width: 100vw;
min-height: 100vh;
padding: 34px 16%;
}

View file

@ -0,0 +1,32 @@
import SurveyInfo from "../SurveyInfo/SurveyInfo.tsx";
import QuestionsList, {Question} from "../QuestionsList/QuestionsList.tsx";
import {useState} from "react";
import styles from './CompletingSurvey.module.css'
export const CompletingSurvey = () => {
const [titleSurvey, setTitleSurvey] = useState("Название опроса");
const [descriptionSurvey, setDescriptionSurvey] = useState("");
const [questions, setQuestions] = useState<Question[]>([
{ id: 1, text: 'Вопрос 1', questionType: 'SingleAnswerQuestion', answerVariants: [{ id: 1, text: 'Ответ 1' },
{ id: 2, text: 'Ответ 1' }, { id: 3, text: 'Ответ 1' }]},
{ id: 2, text: 'Вопрос 2', questionType: 'MultipleAnswerQuestion', answerVariants: [{ id: 1, text: 'Ответ 1' },
{ id: 2, text: 'Ответ 1' }, { id: 3, text: 'Ответ 1' }]}
]);
return (
<div className={styles.survey}>
<SurveyInfo
titleSurvey={titleSurvey}
descriptionSurvey={descriptionSurvey}
setDescriptionSurvey={setDescriptionSurvey}
setTitleSurvey={setTitleSurvey}
/>
<QuestionsList
questions={questions}
setQuestions={setQuestions}
/>
</div>
)
}
export default CompletingSurvey

View file

@ -12,8 +12,7 @@
gap: 60px;
list-style: none;
align-items: center;
margin-right: 40%;
margin-right: 20%;
}
.pageLink{

View file

@ -7,9 +7,13 @@ import {Link, useLocation, useNavigate} from "react-router-dom";
const Header: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const isCreateSurveyActive = location.pathname.includes('/survey/create');
const isSurveyPage = location.pathname.includes('/survey/') && !location.pathname.includes('/survey/create');
const isMySurveysPage = location.pathname === '/my-surveys' || isSurveyPage;
const isCreateSurveyActive = location.pathname.startsWith('/survey/create');
const isMySurveysActive = location.pathname === '/my-surveys';
const isCompleteSurveyActive = location.pathname === '/complete-survey';
const isSurveyViewPage = location.pathname.startsWith('/survey/') &&
!location.pathname.startsWith('/survey/create');
const handleLogoClick = () => {
navigate(location.pathname, { replace: true });
@ -19,15 +23,26 @@ const Header: React.FC = () => {
<div className={styles.header}>
<Logo href={location.pathname} onClick={handleLogoClick} />
<nav className={styles.pagesNav}>
<Link to='/survey/create/questions'
className={`${styles.pageLink} ${isCreateSurveyActive ? styles.active : ''}`}>
<Link
to='/survey/create/questions'
className={`${styles.pageLink} ${isCreateSurveyActive ? styles.active : ''}`}
>
Создать опрос
{isCreateSurveyActive && <hr className={styles.activeLine}/>}
</Link>
<Link to='/my-surveys'
className={`${styles.pageLink} ${isMySurveysPage ? styles.active : ''}`}>
<Link
to='/my-surveys'
className={`${styles.pageLink} ${isMySurveysActive || isSurveyViewPage ? styles.active : ''}`}
>
Мои опросы
{isMySurveysPage && <hr className={styles.activeLine}/>}
{(isMySurveysActive || isSurveyViewPage) && <hr className={styles.activeLine}/>}
</Link>
<Link
to='/complete-survey'
className={`${styles.pageLink} ${isCompleteSurveyActive ? styles.active : ''}`}
>
Прохождение опроса
{isCompleteSurveyActive && <hr className={styles.activeLine}/>}
</Link>
</nav>
<Account href={'/profile'} />

View file

@ -22,11 +22,15 @@ const LoginForm = () => {
setError(null);
try{
const responseData = await authUser({email, password});
if (responseData && !responseData.error)
navigate('/my-surveys');
else
setError('Неверный логин или пароль')
if (email === '' || password === '')
setError('Заполните все поля')
else {
const responseData = await authUser({email, password});
if (responseData && !responseData.error)
navigate('/my-surveys');
else
setError('Неверный логин или пароль')
}
}
catch(err){
console.error('Ошибка при отправке запроса:', err);

View file

@ -13,6 +13,17 @@ export const MySurveyList = () => {
const navigate = useNavigate();
const [surveys, setSurveys] = useState<MySurveyItem[]>([]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}`;
};
useEffect(() => {
const fetchSurvey = async () => {
try {
@ -24,13 +35,6 @@ export const MySurveyList = () => {
setSurveys(surveysWithStatus);
} catch (error) {
console.error('Ошибка при получении списка опросов:', error);
if (error instanceof Error && error.message.includes("401")) {
// Если ошибка 401, перенаправляем на страницу входа
navigate('/login');
} else {
alert("Ошибка при загрузке опросов: " + (error instanceof Error && error.message));
}
}
};
fetchSurvey();
@ -52,12 +56,6 @@ export const MySurveyList = () => {
}
} catch (error) {
console.error('Ошибка при удалении опроса:', error);
if (error instanceof Error && error.message.includes("401")) {
navigate('/login');
} else {
alert("Ошибка при удалении опроса: " + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
}
}
};
@ -76,7 +74,7 @@ export const MySurveyList = () => {
<h1 className={styles.title}>{survey.title}</h1>
<h2 className={styles.description}>{survey.description}</h2>
</div>
<span className={styles.date}>Дата создания: {survey.createdBy}</span>
<span className={styles.date}>Дата создания: {formatDate(survey.createdAt)}</span>
</div>
<div className={styles.container}>
<div className={`${styles.status} ${

View file

@ -1,9 +1,9 @@
.main {
background-color: #F6F6F6;
width: 100%;
max-width: 100vw;
min-height: 100vh;
padding: 34px 10%;
padding-top: 34px;
padding-left: 12%;
}
.survey {

View file

@ -4,65 +4,108 @@ import AddAnswerButton from "../AddAnswerButton/AddAnswerButton.tsx";
import TypeDropdown from "../TypeDropdown/TypeDropdown.tsx";
import styles from './QuestionItem.module.css'
import Delete from '../../assets/deleteQuestion.svg?react';
import {
addNewAnswerVariant,
deleteAnswerVariant,
getAnswerVariants,
updateAnswerVariant
} from "../../api/AnswerApi.ts";
import {useLocation} from "react-router-dom";
import TextareaAutosize from "react-textarea-autosize";
interface QuestionItemProps {
questionId: number;
initialTextQuestion?: string;
valueQuestion: string;
answerVariants: {id?: number, text: string}[];
onChangeQuestion: (valueQuestion: string) => void;
onAnswerVariantsChange: (variants: {id?: number, text: string}[]) => void;
onDeleteQuestion: (index: number) => Promise<void>;
selectedType: 'single' | 'multiply'; // Уточняем тип
setSelectedType: (type: 'single' | 'multiply') => void; // Уточняем тип
initialQuestionType: 'SingleAnswerQuestion' | 'MultipleAnswerQuestion';
onQuestionTypeChange: (type: 'SingleAnswerQuestion' | 'MultipleAnswerQuestion') => void;
surveyId?: number;
}
const QuestionItem: React.FC<QuestionItemProps> = ({questionId, initialTextQuestion = `Вопрос ${questionId}`,
valueQuestion, onChangeQuestion, onDeleteQuestion, setSelectedType, selectedType}) => {
// const [selectedType, setSelectedType] = useState<'single' | 'multiply'>('single');
const [answerOption, setAnswerOption] = useState(['']);
const QuestionItem: React.FC<QuestionItemProps> = ({
questionId,
initialTextQuestion = `Вопрос`,
valueQuestion,
answerVariants: initialAnswerVariants,
onChangeQuestion,
onAnswerVariantsChange,
onDeleteQuestion,
initialQuestionType,
onQuestionTypeChange,
surveyId
}) => {
const [textQuestion, setTextQuestion] = useState(initialTextQuestion);
const [isEditingQuestion, setIsEditingQuestion] = useState(false);
const [selectedAnswers, setSelectedAnswers] = useState<number[]>([]);
const [questionType, setQuestionType] = useState<'SingleAnswerQuestion' | 'MultipleAnswerQuestion'>(initialQuestionType);
const textareaQuestionRef = useRef<HTMLTextAreaElement>(null);
const handleTypeChange = (type: 'single' | 'multiply') => {
setSelectedType(type);
}
const location = useLocation();
const isCompleteSurveyActive = location.pathname === '/complete-survey';
const handleAddAnswer = () => {
setAnswerOption([...answerOption, '']);
useEffect(() => {
setTextQuestion(valueQuestion);
}, [valueQuestion]);
useEffect(() => {
setQuestionType(initialQuestionType);
}, [initialQuestionType]);
const handleTypeChange = (type: 'SingleAnswerQuestion' | 'MultipleAnswerQuestion') => {
setQuestionType(type);
onQuestionTypeChange(type);
};
const handleAddAnswer = async () => {
if (!surveyId) {
onAnswerVariantsChange([...initialAnswerVariants, { text: '' }]);
return;
}
try {
const newAnswer = await addNewAnswerVariant(surveyId, questionId, { text: '' });
onAnswerVariantsChange([...initialAnswerVariants, {
id: newAnswer.id,
text: newAnswer.text
}]);
} catch (error) {
console.error('Ошибка при добавлении варианта ответа:', error);
}
};
const handleQuestionClick = () => {
setIsEditingQuestion(true);
}
};
const handleTextareaQuestionChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setTextQuestion(event.target.value);
if (textareaQuestionRef.current) {
textareaQuestionRef.current.style.height = 'auto';
textareaQuestionRef.current.style.height = `${textareaQuestionRef.current.scrollHeight}px`;
}
}
};
const handleSaveQuestion = () => {
setIsEditingQuestion(false);
onChangeQuestion(textQuestion);
}
};
const handleQuestionKeyDown = (keyDownEvent: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (keyDownEvent.key === 'Enter') {
keyDownEvent.preventDefault();
handleSaveQuestion()
handleSaveQuestion();
}
}
};
const handleQuestionBlur = () => {
handleSaveQuestion()
}
handleSaveQuestion();
};
useEffect(() => {
if (isEditingQuestion && textareaQuestionRef.current) {
@ -72,21 +115,49 @@ const QuestionItem: React.FC<QuestionItemProps> = ({questionId, initialTextQuest
}
}, [isEditingQuestion]);
const handleAnswerChange = (index: number, value: string) => {
const newAnswerOption = [...answerOption];
newAnswerOption[index] = value;
setAnswerOption(newAnswerOption);
}
const handleAnswerChange = async (index: number, value: string) => {
const newAnswerVariants = [...initialAnswerVariants];
newAnswerVariants[index] = { ...newAnswerVariants[index], text: value };
onAnswerVariantsChange(newAnswerVariants);
const handleDeleteAnswer = (index: number) => {
const newAnswerOption = answerOption.filter((_, i) => i !== index);
setAnswerOption(newAnswerOption);
setSelectedAnswers(selectedAnswers.filter((i) => i !== index));
if (surveyId && newAnswerVariants[index].id) {
try {
await updateAnswerVariant(
surveyId,
questionId,
newAnswerVariants[index].id!,
{ text: value }
);
} catch (error) {
console.error('Ошибка при обновлении варианта ответа:', error);
}
}
};
useEffect(() => {
setTextQuestion(valueQuestion);
}, [valueQuestion]);
const handleDeleteAnswer = async (index: number) => {
const answerToDelete = initialAnswerVariants[index];
if (surveyId && answerToDelete.id) {
try {
await deleteAnswerVariant(surveyId, questionId, answerToDelete.id);
const newAnswerVariants = initialAnswerVariants.filter((_, i) => i !== index);
onAnswerVariantsChange(newAnswerVariants);
setSelectedAnswers(selectedAnswers.filter((i) => i !== index));
if (surveyId) {
const variants = await getAnswerVariants(surveyId, questionId);
const answers = variants.map((v: { id: number, text: string }) => ({ id: v.id, text: v.text }));
onAnswerVariantsChange(answers);
}
} catch (error) {
console.error('Ошибка при удалении варианта ответа:', error);
}
} else {
const newAnswerVariants = initialAnswerVariants.filter((_, i) => i !== index);
onAnswerVariantsChange(newAnswerVariants);
setSelectedAnswers(selectedAnswers.filter((i) => i !== index));
}
};
const handleDeleteQuestion = async () => {
try {
@ -97,66 +168,83 @@ const QuestionItem: React.FC<QuestionItemProps> = ({questionId, initialTextQuest
};
const toggleSelect = (index: number) => {
if (selectedType === 'single') {
if (initialQuestionType === 'SingleAnswerQuestion') {
// Для одиночного выбора: заменяем массив одним выбранным индексом
setSelectedAnswers([index]);
} else {
setSelectedAnswers((prev) => {
if (prev.includes(index)) {
return prev.filter((i) => i !== index);
} else {
return [...prev, index];
}
});
// Для множественного выбора: добавляем/удаляем индекс
setSelectedAnswers(prev =>
prev.includes(index)
? prev.filter(i => i !== index)
: [...prev, index]
);
}
};
return (
<div className={styles.questionCard}>
<div className={styles.questionContainer}>
<div className={styles.question}>
{isEditingQuestion ? (
<textarea
className={styles.questionTextarea}
ref={textareaQuestionRef}
value={textQuestion === initialTextQuestion ? '' : textQuestion}
onChange={handleTextareaQuestionChange}
onKeyDown={handleQuestionKeyDown}
onBlur={handleQuestionBlur}
placeholder={initialTextQuestion}
rows={1}
{isCompleteSurveyActive ? (
<div>
<div className={styles.questionContainer}>
<h2 className={styles.textQuestion}>{textQuestion || initialTextQuestion}</h2>
</div>
{initialAnswerVariants.map((answer, index) => (
<AnswerOption
key={answer.id || index}
selectedType={initialQuestionType}
index={index + 1}
value={answer.text}
isSelected={selectedAnswers.includes(index)}
toggleSelect={() => toggleSelect(index)}
isCompleteSurveyActive={isCompleteSurveyActive}
/>
) : (
<button className={styles.buttonQuestion} onClick={handleQuestionClick}>
<h2 className={styles.textQuestion}>{textQuestion || initialTextQuestion}</h2>
</button>
)}
<TypeDropdown selectedType={selectedType} onTypeChange={handleTypeChange}/>
))}
</div>
) : (
<div className={styles.questionContainer}>
<div className={styles.question}>
{isEditingQuestion ? (
<TextareaAutosize
className={styles.questionTextarea}
ref={textareaQuestionRef}
value={textQuestion === initialTextQuestion ? '' : textQuestion}
onChange={handleTextareaQuestionChange}
onKeyDown={handleQuestionKeyDown}
onBlur={handleQuestionBlur}
placeholder={initialTextQuestion}
rows={1}
/>
) : (
<button className={styles.buttonQuestion} onClick={handleQuestionClick}>
<h2 className={styles.textQuestion}>{textQuestion || initialTextQuestion}</h2>
</button>
)}
<TypeDropdown selectedType={questionType} onTypeChange={handleTypeChange}/>
</div>
{answerOption.map((answerText, index) => (
<AnswerOption
key={index}
selectedType={selectedType}
index={index + 1}
value={answerText}
onChange={(value) => handleAnswerChange(index, value)}
onDelete={() => handleDeleteAnswer(index)}
toggleSelect={() => toggleSelect(index)}
/>
))}
{initialAnswerVariants.map((answer, index) => (
<AnswerOption
key={answer.id || index}
selectedType={questionType}
index={index + 1}
value={answer.text}
onChange={(value) => handleAnswerChange(index, value)}
onDelete={() => handleDeleteAnswer(index)}
toggleSelect={() => toggleSelect(index)}
/>
))}
<div className={styles.questionActions}>
<AddAnswerButton
onClick={handleAddAnswer}
/>
<button className={styles.deleteQuestionButton} onClick={handleDeleteQuestion}>
Удалить{/**/}
<Delete className={styles.basketImg}/>
</button>
</div>
</div>
<div className={styles.questionActions}>
<AddAnswerButton onClick={handleAddAnswer} />
<button className={styles.deleteQuestionButton} onClick={handleDeleteQuestion}>
Удалить
<Delete className={styles.basketImg}/>
</button>
</div>
</div>)
}
</div>
);
}
};
export default QuestionItem;

View file

@ -1,2 +1,16 @@
/*QuestionsList.module.css*/
.departur_button{
display: block;
margin: 10px auto;
padding: 25px 50.5px;
border: none;
border-radius: 20px;
background-color: #3788D6;
color: white;
font-weight: 700;
font-size: 24px;
text-align: center;
box-shadow: 0 0 7.4px 0 rgba(154, 202, 247, 1);
box-sizing: border-box;
}

View file

@ -1,7 +1,10 @@
import React, {useEffect, useState} from "react";
import React from "react";
import QuestionItem from "../QuestionItem/QuestionItem.tsx";
import AddQuestionButton from "../AddQuestionButton/AddQuestionButton.tsx";
import {deleteQuestion} from "../../api/QuestionApi.ts";
import {addNewQuestion, deleteQuestion, getListQuestions} from "../../api/QuestionApi.ts";
import {addNewAnswerVariant} from "../../api/AnswerApi.ts";
import {useLocation} from "react-router-dom";
import styles from './QuestionsList.module.css'
interface QuestionsListProps {
questions: Question[];
@ -12,29 +15,51 @@ interface QuestionsListProps {
export interface Question {
id: number;
text: string;
questionType: 'singleanswerquestion' | 'multipleanswerquestion';
questionType: 'SingleAnswerQuestion' | 'MultipleAnswerQuestion';
answerVariants: Array<{
id?: number;
text: string;
}>;
}
const QuestionsList: React.FC<QuestionsListProps> = ({questions, setQuestions, surveyId}) => {
const [selectedType, setSelectedType] = useState<'single' | 'multiply'>('single');
const location = useLocation();
const isCompleteSurveyActive = location.pathname === '/complete-survey';
const [localQuestionId, setLocalQuestionId] = useState(2); // Начинаем с 2, так как первый вопрос имеет ID=1
const handleAddQuestion = async () => {
if (!surveyId) {
const newQuestion: Question = {
id: questions.length + 1,
text: '',
questionType: 'SingleAnswerQuestion',
answerVariants: [{ text: '' }],
};
setQuestions([...questions, newQuestion]);
return;
}
const handleAddQuestion = () => {
const newQuestion: Question = {
id: localQuestionId,
text: '',
questionType: selectedType === 'single' ? 'singleanswerquestion' : 'multipleanswerquestion',
};
setQuestions([...questions, newQuestion]);
setLocalQuestionId(localQuestionId + 1);
try {
const newQuestion = await addNewQuestion(surveyId, {
title: '',
questionType: 'SingleAnswerQuestion'
});
const questionToAdd: Question = {
id: newQuestion.id,
text: newQuestion.title,
questionType: newQuestion.questionType,
answerVariants: []
};
const newAnswer = await addNewAnswerVariant(surveyId, newQuestion.id, { text: '' });
questionToAdd.answerVariants = [{ id: newAnswer.id, text: newAnswer.text }];
setQuestions([...questions, questionToAdd]);
} catch (error) {
console.error('Ошибка при добавлении вопроса:', error);
}
};
useEffect(() => {
setLocalQuestionId(questions.length > 0 ?
Math.max(...questions.map(q => q.id)) + 1 : 1);
}, [questions]);
const handleQuestionChange = (id: number, value: string) => {
const newQuestions = questions.map((question) =>
question.id === id ? { ...question, text: value } : question
@ -45,24 +70,48 @@ const QuestionsList: React.FC<QuestionsListProps> = ({questions, setQuestions, s
const handleDeleteQuestion = async (id: number) => {
try {
if (surveyId) {
const response = await deleteQuestion(surveyId, id);
if (!response?.success) {
throw new Error('Не удалось удалить вопрос на сервере');
const listQuestions = await getListQuestions(surveyId);
if (listQuestions.find(q => q.id === id)) {
const response = await deleteQuestion(surveyId, id);
if (!response?.success) {
throw new Error('Не удалось удалить вопрос на сервере');
}
}
}
const newQuestions: Question[] = [];
for (const question of questions) {
if (question.id !== id) {
newQuestions.push(question);
const newQuestions: Question[] = [];
for (const question of questions) {
if (question.id !== id) {
newQuestions.push(question);
}
}
setQuestions(newQuestions);
}
else{
const questionsList = questions.filter(q => q.id !== id);
setQuestions(questionsList);
return;
}
setQuestions(newQuestions);
} catch (error) {
console.error('Ошибка при удалении вопроса:', error);
alert('Не удалось удалить вопрос: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
}
};
const handleAnswerVariantsChange = (questionId: number, newAnswerVariants: {id?: number, text: string}[]) => {
const newQuestions = questions.map(question =>
question.id === questionId
? {...question, answerVariants: newAnswerVariants}
: question
);
setQuestions(newQuestions);
};
const handleQuestionTypeChange = (questionId: number, newType: 'SingleAnswerQuestion' | 'MultipleAnswerQuestion') => {
setQuestions(questions.map(question =>
question.id === questionId
? {...question, questionType: newType}
: question
));
};
return (
<>
{questions.map((question) => (
@ -70,13 +119,19 @@ const QuestionsList: React.FC<QuestionsListProps> = ({questions, setQuestions, s
key={question.id}
questionId={question.id}
valueQuestion={question.text}
answerVariants={question.answerVariants}
onAnswerVariantsChange={(variants) => handleAnswerVariantsChange(question.id, variants)}
onDeleteQuestion={() => handleDeleteQuestion(question.id)}
onChangeQuestion={(value) => handleQuestionChange(question.id, value)}
selectedType={selectedType}
setSelectedType={setSelectedType}
initialQuestionType={question.questionType}
onQuestionTypeChange={(type) => handleQuestionTypeChange(question.id, type)}
surveyId={surveyId}
/>
))}
<AddQuestionButton onClick={handleAddQuestion} />
{!isCompleteSurveyActive ? <AddQuestionButton onClick={handleAddQuestion} /> : (
<button className={styles.departur_button}>Отправить</button>
)}
</>
);
};

View file

@ -31,7 +31,7 @@
color: #000000;
outline: none;
border: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
border-bottom: 2px solid rgba(0, 0, 0, 0.2);
padding: 5px 0;
opacity: 1;
}
@ -48,14 +48,13 @@
opacity: 0;
}
/* Отключаем стиль для input, когда в нём есть данные */
.input:not(:placeholder-shown) {
color: black;
opacity: 1;
}
.input:focus {
border-bottom: 1px solid black; /* Чёрная граница при фокусе */
border-bottom: 2px solid black;
}
.signUp{

View file

@ -31,17 +31,22 @@ const RegisterForm = () => {
setError(null);
try{
const responseData = await registerUser({username, firstName, lastName, email, password});
if (responseData && !responseData.error) {
console.log('Регистрация успешна');
localStorage.setItem("user", JSON.stringify({
firstName,
lastName
}));
navigate('/my-surveys');
if (email === '' || password === '' || firstName === '' || lastName === '') {
setError('Заполните все поля');
}
else if (responseData.status === 409){
setError('Аккаунт с такой почтой уже зарегистрирован');
else{
const responseData = await registerUser({username, firstName, lastName, email, password});
if (responseData && !responseData.error) {
console.log('Регистрация успешна');
localStorage.setItem("user", JSON.stringify({
firstName,
lastName
}));
navigate('/my-surveys');
}
else if (responseData.status === 409){
setError('Аккаунт с такой почтой уже зарегистрирован');
}
}
}
catch (err) {

View file

@ -1,5 +1,148 @@
/*Results.module.css*/
/* Results.module.css */
.results{
.results {
width: 85%;
}
margin: 19px 30px;
}
.statsContainer {
display: flex;
justify-content: space-between;
align-items: stretch;
margin: 30px 0;
gap: 17px;
}
.statItem {
display: flex;
flex-direction: column;
border-radius: 15px;
min-height: 180px;
padding: 20px;
box-sizing: border-box;
position: relative;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.countAnswer {
width: 36%;
background-color: #65B953;
}
.completion_percentage {
width: 36%;
background-color: #EEDD59;
}
.status {
width: 24%;
background-color: #A763EB;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.statItem h3 {
margin: 0 0 15px 0;
color: #FFFFFF;
font-size: 28px;
font-weight: 600;
line-height: 1.2;
}
.result {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: auto;
}
.statItem p {
padding: 20px;
font-weight: 600;
font-size: 40px;
color: #FFFFFF;
margin: 0;
line-height: 1;
}
.countAnswer p,
.completion_percentage p {
font-size: 60px;
}
.imgGroup,
.imgSend {
width: 58px;
height: 61px;
align-self: flex-end;
}
.status p {
text-align: center;
margin-top: auto;
font-size: 32px;
}
.questionContainer {
display: flex;
flex-direction: column;
margin-bottom: 40px;
padding: 25px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.questionContent {
display: flex;
flex-direction: row;
gap: 40px;
align-items: flex-start;
width: 100%;
}
.textContainer {
display: flex;
flex-direction: column;
gap: 11px;
width: 30%;
min-width: 250px;
}
.questionContainer h3 {
font-size: 24px;
font-weight: 600;
color: #000000;
margin: 0;
}
.answerCount {
color: #000000;
font-size: 18px;
font-weight: 600;
}
.chartContainer {
flex: 1;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.pieContainer {
width: 100%;
height: 450px;
position: relative;
}
.barContainer {
width: 100%;
height: 450px;
display: flex;
justify-content: center;
align-items: center;
padding-right: 150px;
}

View file

@ -1,19 +1,208 @@
import SurveyInfo from "../SurveyInfo/SurveyInfo.tsx";
import styles from './Results.module.css'
import {useState} from "react";
import styles from './Results.module.css';
import {Bar, Pie} from 'react-chartjs-2';
import {Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title} from 'chart.js';
import {useOutletContext} from "react-router-dom";
import {ISurvey} from "../../api/SurveyApi.ts";
import ChartDataLabels from 'chartjs-plugin-datalabels';
import annotationPlugin from 'chartjs-plugin-annotation';
import Group from '../../assets/gmail_groups.svg?react';
import Send from '../../assets/send.svg?react';
ChartJS.register(
ArcElement, Tooltip, Legend,
CategoryScale, LinearScale, BarElement, Title, ChartDataLabels, annotationPlugin
);
// Типы для данных
interface QuestionStats {
questionText: string;
totalAnswers: number;
options: {
text: string;
percentage: number;
}[];
isMultipleChoice?: boolean;
}
export const Results = () => {
const [descriptionSurvey, setDescriptionSurvey] = useState('');
const [titleSurvey, setTitleSurvey] = useState('Название опроса');
const { survey, setSurvey } = useOutletContext<{
survey: ISurvey;
setSurvey: (survey: ISurvey) => void;
}>();
return(
const surveyStats = {
totalParticipants: 100,
completionPercentage: 80,
status: 'Активен',
questions: [
{
questionText: "Вопрос 1",
totalAnswers: 80,
options: [
{ text: "Вариант 1", percentage: 46 },
{ text: "Вариант 2", percentage: 15 },
{ text: "Вариант 3", percentage: 39 }
],
isMultipleChoice: false
},
{
questionText: "Вопрос 2",
totalAnswers: 100,
options: [
{ text: "Вариант 1", percentage: 50 },
{ text: "Вариант 2", percentage: 20 },
{ text: "Вариант 3", percentage: 100 },
{ text: "Вариант 4", percentage: 80 }
],
isMultipleChoice: true
}
] as QuestionStats[]
};
// Цветовая палитра
const colorsForPie = ['#67C587', '#C9EAD4', '#EAF6ED'];
const colorsForBar = ['#8979FF'];
return (
<div className={styles.results}>
<SurveyInfo
titleSurvey={titleSurvey}
descriptionSurvey={descriptionSurvey}
setDescriptionSurvey={setDescriptionSurvey}
setTitleSurvey={setTitleSurvey}
titleSurvey={survey.title}
descriptionSurvey={survey.description}
setDescriptionSurvey={(value) => setSurvey({ ...survey, description: value })}
setTitleSurvey={(value) => setSurvey({ ...survey, title: value })}
/>
<div className={styles.statsContainer}>
<div className={`${styles.statItem} ${styles.countAnswer}`}>
<h3>Количество ответов</h3>
<div className={styles.result}>
<p>{surveyStats.totalParticipants}</p>
<Group className={styles.imgGroup}/>
</div>
</div>
<div className={`${styles.statItem} ${styles.completion_percentage}`}>
<h3>Процент завершения</h3>
<div className={styles.result}>
<p>{surveyStats.completionPercentage}%</p>
<Send className={styles.imgSend}/>
</div>
</div>
<div className={`${styles.statItem} ${styles.status}`}>
<h3>Статус опроса</h3>
<p>{surveyStats.status}</p>
</div>
</div>
{surveyStats.questions.map((question, index) => (
<div key={index} className={styles.questionContainer}>
<div className={styles.questionContent}>
<div className={styles.textContainer}>
<h3>{question.questionText}</h3>
<p className={styles.answerCount}>Ответов: {question.totalAnswers}</p>
</div>
<div className={styles.chartContainer}>
{question.isMultipleChoice ? (
<div className={styles.barContainer}>
<Bar
data={{
labels: question.options.map(opt => opt.text),
datasets: [{
label: '% выбравших',
data: question.options.map(opt => opt.percentage),
backgroundColor: colorsForBar,
borderColor: colorsForBar,
borderWidth: 2,
borderRadius: 8,
borderSkipped: false,
}]
}}
options={{
responsive: true,
plugins: {
legend: {
display: false
},
tooltip: { enabled: true },
datalabels: { display: false },
annotation: {
annotations: question.options.map((opt, i) => ({
type: 'label',
xValue: i,
yValue: opt.percentage + 5,
content: `${opt.percentage}%`,
font: { size: 16, weight: 400 },
color: '#000'
}))
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: { callback: (val) => `${val}%` }
},
x: {
ticks: {
color: '#000000',
font: {
size: 16,
weight: 400
}
},
grid: { display: false }
}
}
}}
/>
</div>
) : (
<div className={styles.pieContainer}>
<Pie
data={{
labels: question.options.map(opt => opt.text),
datasets: [{
data: question.options.map(opt => opt.percentage),
backgroundColor: colorsForPie,
borderColor: '#fff',
borderWidth: 2
}]
}}
options={{
responsive: true,
plugins: {
legend: {
position: 'right',
labels: {
color: '#000000',
font: {
size: 18,
weight: 500
}
}
},
tooltip: {
callbacks: {
label: (ctx) => `${ctx.label}: ${ctx.raw}%`
}
},
datalabels: {
formatter: (value) => `${value}%`,
color: '#000',
font: { weight: 400, size: 16 }
}
},
animation: { animateRotate: true }
}}
/>
</div>
)}
</div>
</div>
</div>
))}
</div>
)
}
);
};

View file

@ -2,6 +2,7 @@
.settingSurvey{
width: 85%;
height: 100vh;
}
.startEndTime{
@ -16,6 +17,7 @@
padding-top: 15px;
padding-bottom: 97px;
padding-left: 19px;
margin-bottom: 30px;
}
.param h2{

View file

@ -1,20 +1,25 @@
import React, {useState} from 'react';
import React from 'react';
import SurveyInfo from "../SurveyInfo/SurveyInfo.tsx";
import styles from "./SettingSurvey.module.css";
import TimeEvent from "../TimeEvent/TimeEvent.tsx";
import SaveButton from "../SaveButton/SaveButton.tsx";
import {ISurvey} from "../../api/SurveyApi.ts";
import {useOutletContext} from "react-router-dom";
const SettingSurvey: React.FC = () => {
const [descriptionSurvey, setDescriptionSurvey] = useState('');
const [titleSurvey, setTitleSurvey] = useState('Название опроса');
const { survey, setSurvey } = useOutletContext<{
survey: ISurvey;
setSurvey: (survey: ISurvey) => void;
}>();
return (
<div className={styles.settingSurvey}>
<SurveyInfo
titleSurvey={titleSurvey}
descriptionSurvey={descriptionSurvey}
setDescriptionSurvey={setDescriptionSurvey}
setTitleSurvey={setTitleSurvey}
titleSurvey={survey.title}
descriptionSurvey={survey.description}
setDescriptionSurvey={(value) => setSurvey({ ...survey, description: value })}
setTitleSurvey={(value) => setSurvey({ ...survey, title: value })}
/>
<div className={styles.startEndTime}>
<TimeEvent title='Время начала'/>
@ -23,6 +28,7 @@ const SettingSurvey: React.FC = () => {
<div className={styles.param}>
<h2>Параметры видимости</h2>
</div>
<SaveButton onClick={() => {}}/>
</div>
)
}

View file

@ -6,6 +6,7 @@ import SaveButton from "../SaveButton/SaveButton.tsx";
import {ISurvey, postNewSurvey} from "../../api/SurveyApi.ts";
import {addNewQuestion} from "../../api/QuestionApi.ts";
import {useNavigate} from "react-router-dom";
import {addNewAnswerVariant} from "../../api/AnswerApi.ts";
const Survey: React.FC = () => {
const navigate = useNavigate();
@ -14,25 +15,9 @@ const Survey: React.FC = () => {
const [survey] = useState<ISurvey | null>(null);
const [questions, setQuestions] = useState<Question[]>([
{ id: 1, text: '', questionType: 'singleanswerquestion'},
{ id: 1, text: '', questionType: 'SingleAnswerQuestion', answerVariants: [{ text: '' }]},
]);
// const handleSave = async () => {
// const savedSurvey = await postNewSurvey({title: titleSurvey, description: descriptionSurvey});
// setSurvey(savedSurvey);
// Promise.all(
// questions
// .map((question) => addNewQuestion( savedSurvey.id, {title: question.text, questionType: question.questionType })),
// )
// .then(() => {
// alert('Все удачно сохранилось');
// })
// .catch(() => {
// alert('Пиздец');
// });
// };
const handleSave = async () => {
try {
const savedSurvey = await postNewSurvey({
@ -40,20 +25,43 @@ const Survey: React.FC = () => {
description: descriptionSurvey
});
await Promise.all(
questions.map(question =>
addNewQuestion(savedSurvey.id, {
title: question.text,
questionType: question.questionType
})
)
);
const updatedQuestions: Question[] = [];
for (const question of questions) {
const newQuestion = await addNewQuestion(savedSurvey.id, {
title: question.text,
questionType: question.questionType
});
const updatedQuestion: Question = {
...question,
id: newQuestion.id,
answerVariants: []
};
if (question.answerVariants && question.answerVariants.length > 0) {
const newVariants = await Promise.all(
question.answerVariants.map(answer =>
addNewAnswerVariant(
savedSurvey.id,
newQuestion.id,
{ text: answer.text }
)
)
);
updatedQuestion.answerVariants = newVariants.map((variant: { id: number, text: string }) => ({
id: variant.id,
text: variant.text
}));
}
updatedQuestions.push(updatedQuestion);
}
setQuestions(updatedQuestions);
navigate('/my-surveys');
} catch (error) {
console.error('Ошибка при сохранении:', error);
alert('Не удалось сохранить опрос');
}
};

View file

@ -7,37 +7,45 @@
margin-top: 34px;
margin-bottom: 49px;
border-radius: 14px;
min-height: 191px;
/*min-height: 191px;*/
/*max-height: 100vh;*/
max-height: fit-content;
}
.info{
min-width: 373px;
display: block;
/*display: block;*/
padding: 35px;
display: flex; /* Добавляем flex */
flex-direction: column; /* Элементы в колонку */
align-items: center;
}
.titleSurvey{
width: 80%;
display: block;
border: none;
margin: 0 auto;
margin: 0 auto 13px;
background-color: white;
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 23px;
/*margin-bottom: 23px;*/
/*margin-bottom: 15px;*/
word-break: break-word;
padding: 0;
}
.textareaTitle,
.textareaDescrip {
width: 100%;
width: 80%;
max-width: 100%;
resize: none;
border: none;
outline: none;
font-family: inherit;
padding: 0;
margin: 0;
margin: 0 auto;
background: transparent;
display: block;
overflow-y: hidden;
@ -48,7 +56,7 @@
font-weight: 600;
text-align: center;
line-height: 1.2;
min-height: 40px;
min-height: 60px;
}
.textareaDescrip {
@ -67,7 +75,7 @@
.description {
border: none;
font-size: 18px;
font-size: 24px;
font-weight: 500;
text-align: center;
background-color: white;
@ -78,6 +86,15 @@
word-break: break-word;
}
.desc{
font-size: 24px;
font-weight: 500;
background-color: white;
max-width: 80%;
word-break: break-word;
margin: 0;
text-align: center;
}
.descripButton{
border: none;
@ -97,4 +114,11 @@
font-weight: 500;
color: #7D7983;
padding: 10px;
}
.createdAt{
text-align: center;
font-size: 18px;
font-weight: 500;
color: #7D7983;
}

View file

@ -2,27 +2,34 @@ import React, {useState, useRef, useEffect} from "react";
import styles from './SurveyInfo.module.css'
import AddDescripImg from '../../assets/add_circle.svg?react';
import TextareaAutosize from 'react-textarea-autosize';
import {useLocation} from "react-router-dom";
interface SurveyInfoProps {
titleSurvey: string;
descriptionSurvey: string;
setDescriptionSurvey: (text: string) => void;
setTitleSurvey: (text: string) => void;
setDescriptionSurvey?: (text: string) => void;
setTitleSurvey?: (text: string) => void;
createdAt?: Date;
}
const SurveyInfo: React.FC<SurveyInfoProps> = ({titleSurvey, setDescriptionSurvey, descriptionSurvey, setTitleSurvey}) => {
const SurveyInfo: React.FC<SurveyInfoProps> = ({titleSurvey, setDescriptionSurvey, descriptionSurvey, setTitleSurvey, createdAt = new Date()}) => {
const [showDescriptionField, setShowDescriptionField] = useState(false);
const [showNewTitleField, setShowNewTitleField] = useState(false);
const titleTextareaRef = useRef<HTMLTextAreaElement>(null);
const descriptionTextareaRef = useRef<HTMLTextAreaElement>(null);
const location = useLocation();
const isCompleteSurveyActive = location.pathname === '/complete-survey';
const isSurveyViewPage = location.pathname.startsWith('/survey/') &&
!location.pathname.startsWith('/survey/create');
const handleDescriptionChange = (descripEvent: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescriptionSurvey(descripEvent.target.value);
setDescriptionSurvey?.(descripEvent.target.value);
};
const handleNewTitleChange = (titleEvent: React.ChangeEvent<HTMLTextAreaElement>) => {
setTitleSurvey(titleEvent.target.value);
setTitleSurvey?.(titleEvent.target.value);
};
useEffect(() => {
@ -64,6 +71,7 @@ const SurveyInfo: React.FC<SurveyInfoProps> = ({titleSurvey, setDescriptionSurve
setShowDescriptionField(true);
}
const handleTitleBlur = () => {
setShowNewTitleField(false);
};
@ -72,8 +80,52 @@ const SurveyInfo: React.FC<SurveyInfoProps> = ({titleSurvey, setDescriptionSurve
setShowDescriptionField(false);
};
const addDate = () => {
const year = createdAt.getFullYear();
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).padStart(2, '0');
return `${day}/${month}/${year}`;
}
const renderTitle = () => {
if (isCompleteSurveyActive) {
return (
<button className={styles.titleSurvey}>
<h1>{titleSurvey || 'Название опроса'}</h1>
</button>
)
}
if (showNewTitleField) {
return (
<h1 className={styles.titleSurvey}>
<TextareaAutosize
className={styles.textareaTitle}
ref={titleTextareaRef}
value={titleSurvey === 'Название опроса' ? '' : titleSurvey}
placeholder={'Название опроса'}
onChange={handleNewTitleChange}
onKeyDown={handleTitleKeyDown}
onBlur={handleTitleBlur}
/>
</h1>
);
}
return (
<button className={styles.titleSurvey} onClick={handleAddNewTitleClick}>
<h1>{titleSurvey || 'Название опроса'}</h1>
</button>
);
};
const renderDescription = () => {
if (isCompleteSurveyActive) {
return descriptionSurvey ? (
<p className={styles.desc}>{descriptionSurvey}</p>
) : 'Описание';
}
if (descriptionSurvey && !showDescriptionField) {
return (
<button className={styles.description} onClick={handleParagraphClick}>
@ -95,7 +147,8 @@ const SurveyInfo: React.FC<SurveyInfoProps> = ({titleSurvey, setDescriptionSurve
/>
</div>
);
} else {
}
else {
return (
<button
className={styles.descripButton}
@ -106,35 +159,21 @@ const SurveyInfo: React.FC<SurveyInfoProps> = ({titleSurvey, setDescriptionSurve
</button>
);
}
}
};
return (
<div className={styles.blockInfo}>
<div className={styles.info}>
{
showNewTitleField ? (
<h1 className={styles.titleSurvey}>
<TextareaAutosize className={styles.textareaTitle}
ref={titleTextareaRef}
value={titleSurvey === 'Название опроса' ? '' : titleSurvey}
placeholder={'Название опроса'}
onChange={handleNewTitleChange}
onKeyDown={handleTitleKeyDown}
onBlur={handleTitleBlur}
/>
</h1>
) : (
<button className={styles.titleSurvey} onClick={handleAddNewTitleClick}>
<h1>{titleSurvey || 'Название опроса'}</h1>
</button>
)
}
{renderTitle()}
{renderDescription()}
{(isSurveyViewPage || isCompleteSurveyActive) && createdAt && (
<p className={styles.createdAt}>Дата создания: {addDate()}</p>
)}
</div>
</div>
);
};
export default SurveyInfo;

View file

@ -1,30 +1,180 @@
import React, { useEffect, useState } from "react";
import SurveyInfo from "../SurveyInfo/SurveyInfo.tsx";
import QuestionsList, {Question} from "../QuestionsList/QuestionsList.tsx";
import {useEffect, useState} from "react";
import {getSurveyById, ISurvey, updateSurvey} from "../../api/SurveyApi.ts";
import {useParams} from "react-router-dom";
import {getListQuestions} from "../../api/QuestionApi.ts";
import QuestionsList, { Question } from "../QuestionsList/QuestionsList.tsx";
import { ISurvey, updateSurvey } from "../../api/SurveyApi.ts";
import {useOutletContext} from "react-router-dom";
import { addNewQuestion, getListQuestions, updateQuestion, deleteQuestion } from "../../api/QuestionApi.ts";
import styles from "./SurveyPage.module.css";
import SaveButton from "../SaveButton/SaveButton.tsx";
import { addNewAnswerVariant, deleteAnswerVariant, getAnswerVariants, IAnswerVariant, updateAnswerVariant } from "../../api/AnswerApi.ts";
type ActionType =
| 'update-survey'
| 'create-question'
| 'update-question'
| 'delete-question'
| 'create-answer'
| 'update-answer'
| 'delete-answer';
interface SurveyActionData {
id: number;
title: string;
description: string;
}
interface QuestionActionData {
surveyId: number;
id?: number;
title: string;
questionType: 'SingleAnswerQuestion' | 'MultipleAnswerQuestion';
}
interface AnswerActionData {
surveyId: number;
questionId: number;
id?: number;
text: string;
}
interface Action {
type: ActionType;
data: SurveyActionData | QuestionActionData | AnswerActionData;
tempId?: number;
}
class ActionQueue {
private actions: Action[] = [];
private idMap: Record<number, number> = {};
add(action: Action) {
this.actions.push(action);
}
async execute() {
for (const action of this.actions) {
try {
switch (action.type) {
case 'update-survey':
await this.handleUpdateSurvey(action.data as SurveyActionData);
break;
case 'create-question': {
const createdQuestion = await this.handleCreateQuestion(action.data as QuestionActionData);
if (action.tempId) {
this.idMap[action.tempId] = createdQuestion.id;
}
break;
}
case 'update-question':
await this.handleUpdateQuestion(action.data as QuestionActionData & { id: number });
break;
case 'delete-question':
await this.handleDeleteQuestion(action.data as QuestionActionData & { id: number });
break;
case 'create-answer': {
const answerData = action.data as AnswerActionData;
await this.handleCreateAnswer({
...answerData,
questionId: this.idMap[answerData.questionId] || answerData.questionId
});
break;
}
case 'update-answer': {
const updateAnswerData = action.data as AnswerActionData & { id: number };
await this.handleUpdateAnswer({
...updateAnswerData,
questionId: this.idMap[updateAnswerData.questionId] || updateAnswerData.questionId
});
break;
}
case 'delete-answer': {
const deleteAnswerData = action.data as AnswerActionData & { id: number };
await this.handleDeleteAnswer({
...deleteAnswerData,
questionId: this.idMap[deleteAnswerData.questionId] || deleteAnswerData.questionId
});
break;
}
}
} catch (error) {
console.error(`Failed to execute action ${action.type}:`, error);
throw error;
}
}
}
private async handleUpdateSurvey(data: SurveyActionData) {
return await updateSurvey(data.id, {
title: data.title,
description: data.description
});
}
private async handleCreateQuestion(data: QuestionActionData) {
return await addNewQuestion(data.surveyId, {
title: data.title,
questionType: data.questionType
});
}
private async handleUpdateQuestion(data: QuestionActionData & { id: number }) {
return await updateQuestion(data.surveyId, data.id, {
title: data.title,
questionType: data.questionType
});
}
private async handleDeleteQuestion(data: QuestionActionData & { id: number }) {
return await deleteQuestion(data.surveyId, data.id);
}
private async handleCreateAnswer(data: AnswerActionData) {
return await addNewAnswerVariant(data.surveyId, data.questionId, {
text: data.text
});
}
private async handleUpdateAnswer(data: AnswerActionData & { id: number }) {
try {
const result = await updateAnswerVariant(data.surveyId, data.questionId, data.id, {
text: data.text
});
return result;
} catch (error) {
console.error(error);
throw error;
}
}
private async handleDeleteAnswer(data: AnswerActionData & { id: number }) {
return await deleteAnswerVariant(data.surveyId, data.questionId, data.id);
}
}
export const SurveyPage: React.FC = () => {
const [survey, setSurvey] = useState<ISurvey | null>(null);
// const [survey, setSurvey] = useState<ISurvey | null>(null);
const [questions, setQuestions] = useState<Question[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { surveyId } = useParams<{ surveyId: string }>();
// const { surveyId } = useParams<{ surveyId: string }>();
const [description, setDescription] = useState('');
const [title, setTitle] = useState('');
const { survey, setSurvey } = useOutletContext<{
survey: ISurvey;
setSurvey: (survey: ISurvey) => void;
}>();
useEffect(() => {
if (!surveyId) {
if (!survey.id) {
console.error('Survey ID is missing');
return;
}
const id = parseInt(surveyId);
// const id = parseInt(survey.id);
const id = survey.id;
if (isNaN(id)) {
console.error('Invalid survey ID');
return;
@ -33,52 +183,132 @@ export const SurveyPage: React.FC = () => {
const fetchData = async () => {
try {
setLoading(true);
const surveyData = await getSurveyById(id);
setSurvey(surveyData);
setTitle(surveyData.title);
setDescription(surveyData.description);
// const surveyData = await getSurveyById(id);
setSurvey(survey);
setTitle(survey.title);
setDescription(survey.description);
const questionsData = await getListQuestions(id);
const formattedQuestions = questionsData.map(q => ({
id: q.id,
text: q.title,
questionType: q.questionType as 'singleanswerquestion' | 'multipleanswerquestion',
const formattedQuestions = await Promise.all(questionsData.map(async q => {
const answerVariants = await getAnswerVariants(id, q.id);
return {
id: q.id,
text: q.title,
questionType: q.questionType as 'SingleAnswerQuestion' | 'MultipleAnswerQuestion',
answerVariants: answerVariants.map((a: IAnswerVariant) => ({
id: a.id,
text: a.text
}))
};
}));
setQuestions(formattedQuestions);
} catch (error) {
console.error('Ошибка:', error);
setError('Не удалось загрузить опрос')
setError('Не удалось загрузить опрос');
} finally {
setLoading(false);
}
};
fetchData();
}, [surveyId]);
}, [survey.id]);
const handleSave = async () => {
if (!survey.id || !survey) return;
try {
setError(null);
// const id = parseInt(survey.id);
const id = survey.id;
const actionQueue = new ActionQueue();
actionQueue.add({
type: 'update-survey',
data: { id, title, description } as SurveyActionData
});
const serverQuestions = await getListQuestions(id);
questions.forEach(question => {
const serverQuestion = serverQuestions.find(q => q.id === question.id);
if (serverQuestion &&
(serverQuestion.title !== question.text ||
serverQuestion.questionType !== question.questionType)) {
actionQueue.add({
type: 'update-question',
data: {
surveyId: id,
id: question.id,
title: question.text,
questionType: question.questionType // Убедитесь, что передается новый тип
} as QuestionActionData & { id: number }
});
}
});
questions.forEach(question => {
question.answerVariants.forEach(answer => {
if (!answer.id) {
actionQueue.add({
type: 'create-answer',
data: {
surveyId: id,
questionId: question.id,
text: answer.text
} as AnswerActionData
});
} else {
actionQueue.add({
type: 'update-answer',
data: {
surveyId: id,
questionId: question.id,
id: answer.id,
text: answer.text
} as AnswerActionData & { id: number }
});
}
});
});
serverQuestions.forEach(serverQuestion => {
if (!questions.some(q => q.id === serverQuestion.id)) {
actionQueue.add({
type: 'delete-question',
data: {
surveyId: id,
id: serverQuestion.id
} as QuestionActionData & { id: number }
});
}
});
await actionQueue.execute();
const updatedQuestions = await getListQuestions(id);
const formattedQuestions = await Promise.all(updatedQuestions.map(async q => {
const answerVariants = await getAnswerVariants(id, q.id);
return {
id: q.id,
text: q.title,
questionType: q.questionType as 'SingleAnswerQuestion' | 'MultipleAnswerQuestion',
answerVariants: answerVariants.map((a: IAnswerVariant) => ({
id: a.id,
text: a.text
}))
};
}));
setQuestions(formattedQuestions);
} catch (error) {
console.error('Ошибка сохранения:', error);
setError(`Ошибка сохранения: ${error instanceof Error ? error.message : 'Неизвестная ошибка'}`);
}
};
if (loading) return <div>Загрузка...</div>;
if (!survey) return <div>Опрос не найден</div>;
const handleSave = async() => {
if (!surveyId || !survey) return;
try{
setError(null);
const id = parseInt(surveyId);
const surveyUpdated = await updateSurvey(id, {
title: title,
description: description,
})
setSurvey(surveyUpdated);
}
catch(error){
console.error('Ошибка при сохранении опроса:', error);
setError('Не удалось сохранить изменения');
throw error;
}
}
return (
<div className={styles.survey_page}>
<SurveyInfo

View file

@ -23,7 +23,7 @@
.inputDate{
border: 3px solid #007AFF26;
padding: 12px 107px 12px 21px;
font-size: 20px; /*??????????????????????? в макете указано bodyLarge/Size*/
font-size: 20px;
font-weight: 400;
border-radius: 3px;
}
@ -31,7 +31,7 @@
.inputTime{
border: 3px solid #007AFF26;
padding: 12px 42px;
font-size: 20px; /*??????????????????????? в макете указано bodyLarge/Size*/
font-size: 20px;
font-weight: 400;
border-radius: 3px;
}

View file

@ -25,6 +25,7 @@
.selectedTypeIcon {
margin-right: 4px;
width: 22px;
}
.dropdownArrow {

View file

@ -6,8 +6,8 @@ import Multiple from '../../assets/check_box.svg?react';
import styles from './TypeDropdown.module.css'
interface TypeDropdownProps {
selectedType: 'single' | 'multiply';
onTypeChange: (type: 'single' | 'multiply') => void;
selectedType: 'SingleAnswerQuestion' | 'MultipleAnswerQuestion';
onTypeChange: (type: 'SingleAnswerQuestion' | 'MultipleAnswerQuestion') => void;
}
const TypeDropdown: React.FC<TypeDropdownProps> = ({selectedType, onTypeChange}) => {
@ -18,7 +18,7 @@ const TypeDropdown: React.FC<TypeDropdownProps> = ({selectedType, onTypeChange})
setIsOpen(!isOpen);
}
const handleSelect = (value: 'single' | 'multiply') => {
const handleSelect = (value: 'SingleAnswerQuestion' | 'MultipleAnswerQuestion') => {
onTypeChange(value);
setIsOpen(false);
}
@ -39,15 +39,15 @@ const TypeDropdown: React.FC<TypeDropdownProps> = ({selectedType, onTypeChange})
return (
<div className={styles.dropdownContainer} ref={dropdownRef}>
<button className={styles.dropdownButton} onClick={handleToggle}>
{selectedType === 'single' ? <Single className={styles.selectedTypeIcon} /> : <Multiple className={styles.selectedTypeIcon} />}
{selectedType === "single" ? "Одиночный выбор" : "Множественный выбор"}
{selectedType === 'SingleAnswerQuestion' ? <Single className={styles.selectedTypeIcon} /> : <Multiple className={styles.selectedTypeIcon} />}
{selectedType === "SingleAnswerQuestion" ? "Одиночный выбор" : "Множественный выбор"}
{isOpen ? <DropUp className={styles.dropdownArrow} /> : <DropDown className={styles.dropdownArrow}/>}
</button>
{isOpen && (
<ul className={styles.dropdownList}>
<li>
<button onClick={() => handleSelect("single")}
<button onClick={() => handleSelect("SingleAnswerQuestion")}
className={styles.dropdownItem}>
<Single className={styles.dropdownItemIcon}/>{' '}
Одиночный выбор
@ -55,7 +55,7 @@ const TypeDropdown: React.FC<TypeDropdownProps> = ({selectedType, onTypeChange})
</li>
<li>
<button className={styles.dropdownItem}
onClick={() => handleSelect("multiply")}>
onClick={() => handleSelect('MultipleAnswerQuestion')}>
<Multiple className={styles.dropdownItemIcon}/>{' '}
Множественный выбор
</button>

View file

@ -0,0 +1,3 @@
.layout{
width: 100%;
}

View file

@ -0,0 +1,14 @@
import Header from "../../components/Header/Header.tsx";
import styles from './CompleteSurvey.module.css'
import CompletingSurvey from "../../components/CompletingSurvey/CompletingSurvey.tsx";
export const CompleteSurvey = () => {
return(
<div className={styles.layout}>
<Header/>
<CompletingSurvey/>
</div>
)
}
export default CompleteSurvey

View file

@ -2,16 +2,4 @@
.layout{
width: 100%;
}
.main{
width: 100%;
min-height: 85vh;
display: flex;
background-color: #F6F6F6;
}
.content{
width: 100%;
margin-left: 8.9%;
}

View file

@ -1,16 +1,47 @@
import Header from "../../components/Header/Header.tsx";
import Navigation from "../../components/Navigation/Navigation.tsx";
import styles from './SurveyCreateAndEditingPage.module.css'
import { Outlet } from "react-router-dom";
import {Outlet, useParams, useLocation} from "react-router-dom";
import {useEffect, useState} from "react";
import {getSurveyById, ISurvey} from "../../api/SurveyApi.ts";
export const SurveyCreateAndEditingPage = () => {
const { surveyId } = useParams<{ surveyId: string }>();
const location = useLocation();
const [survey, setSurvey] = useState<ISurvey | null>(null);
const [loading, setLoading] = useState(false);
const isCreateMode = location.pathname.includes('/survey/create');
useEffect(() => {
if (isCreateMode || !surveyId) return;
setLoading(true);
const fetchSurvey = async () => {
try {
const data = await getSurveyById(parseInt(surveyId));
setSurvey(data);
} catch (error) {
console.error("Ошибка загрузки опроса:", error);
} finally {
setLoading(false);
}
};
fetchSurvey();
}, [surveyId, isCreateMode]);
if (!isCreateMode) {
if (loading) return <div>Загрузка...</div>;
if (!survey) return <div>Опрос не найден</div>;
}
return (
<div className={styles.layout}>
<Header />
<div className={styles.main}>
<Navigation />
<div className={styles.content}>
<Outlet />
<Outlet context={{ survey, setSurvey }}/>
</div>
</div>
</div>

@ -1 +1 @@
Subproject commit a34c2d20fb04c25bc8120264e92f7ca02d7ed617
Subproject commit 7d47ab9e60645032bc41daee5535aba2b7eeebdf