Survey Widget

This commit is contained in:
Sedat ÖZTÜRK 2026-05-06 10:54:04 +03:00
parent 06558a1284
commit a3f86a6fdc
12 changed files with 227 additions and 21 deletions

View file

@ -6,4 +6,5 @@ namespace Sozsoft.Platform.Intranet;
public interface IIntranetAppService : IApplicationService
{
Task<IntranetDashboardDto> GetIntranetDashboardAsync();
Task UpdateSurveyResponseAsync(SubmitSurveyInput input);
}

View file

@ -14,6 +14,9 @@ public class SurveyDto : FullAuditedEntityDto<Guid>
public bool IsAnonymous { get; set; }
public List<SurveyQuestionDto> Questions { get; set; }
/// <summary>Mevcut kullanıcının bu ankete verdiği cevap. Anonim anketlerde veya henüz cevaplanmadıysa null.</summary>
public SurveyResponseDto MyResponse { get; set; }
}
public class SurveyQuestionDto : FullAuditedEntityDto<Guid>
@ -50,3 +53,16 @@ public class SurveyAnswerDto : FullAuditedEntityDto<Guid>
public string QuestionType { get; set; }
public string Value { get; set; }
}
public class SubmitSurveyInput
{
public Guid SurveyId { get; set; }
public List<SubmitSurveyAnswerInput> Answers { get; set; }
}
public class SubmitSurveyAnswerInput
{
public Guid QuestionId { get; set; }
public string QuestionType { get; set; }
public string Value { get; set; }
}

View file

@ -16,6 +16,7 @@ using Volo.Abp.Domain.Repositories;
using Volo.Abp.Identity;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Uow;
using Microsoft.AspNetCore.Mvc;
namespace Sozsoft.Platform.Intranet;
@ -32,6 +33,8 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
private readonly IRepository<JobPosition, Guid> _jobPositionRepository;
private readonly IRepository<Announcement, Guid> _announcementRepository;
private readonly IRepository<Survey, Guid> _surveyRepository;
private readonly IRepository<SurveyResponse, Guid> _surveyResponseRepository;
private readonly IRepository<SurveyAnswer, Guid> _surveyAnswerRepository;
private readonly IRepository<SocialPost, Guid> _socialPostRepository;
public IntranetAppService(
@ -45,6 +48,8 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
IRepository<JobPosition, Guid> jobPositionRepository,
IRepository<Announcement, Guid> announcementRepository,
IRepository<Survey, Guid> surveyRepository,
IRepository<SurveyResponse, Guid> surveyResponseRepository,
IRepository<SurveyAnswer, Guid> surveyAnswerRepository,
IRepository<SocialPost, Guid> socialPostRepository
)
{
@ -57,6 +62,8 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
_jobPositionRepository = jobPositionRepository;
_announcementRepository = announcementRepository;
_surveyRepository = surveyRepository;
_surveyResponseRepository = surveyResponseRepository;
_surveyAnswerRepository = surveyAnswerRepository;
_socialPostRepository = socialPostRepository;
}
@ -188,12 +195,40 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
var surveys = await AsyncExecuter.ToListAsync(
queryable
.AsNoTracking()
.Where(s => s.Status == "active")
.Include(s => s.Questions)
.ThenInclude(q => q.Options)
);
return ObjectMapper.Map<List<Survey>, List<SurveyDto>>(surveys);
var dtos = ObjectMapper.Map<List<Survey>, List<SurveyDto>>(surveys);
// Tüm anketler için mevcut kullanıcının cevabını çek (anonim dahil — CreatorId ile)
if (CurrentUser.IsAuthenticated)
{
var allSurveyIds = surveys.Select(s => s.Id).ToList();
if (allSurveyIds.Any())
{
var rq = await _surveyResponseRepository.GetQueryableAsync();
var myResponses = await AsyncExecuter.ToListAsync(
rq.AsNoTracking()
.Where(r => allSurveyIds.Contains(r.SurveyId)
&& (r.UserId == CurrentUser.Id || r.CreatorId == CurrentUser.Id))
.Include(r => r.Answers)
);
var responseMap = myResponses.ToDictionary(r => r.SurveyId);
for (var i = 0; i < surveys.Count; i++)
{
if (responseMap.TryGetValue(surveys[i].Id, out var myResponse))
dtos[i].MyResponse = ObjectMapper.Map<SurveyResponse, SurveyResponseDto>(myResponse);
}
}
}
return dtos;
}
private async Task<List<SocialPostDto>> GetSocialPostsAsync()
@ -346,5 +381,79 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
_ => "application/octet-stream"
};
}
[HttpPost]
public async Task UpdateSurveyResponseAsync(SubmitSurveyInput input)
{
var survey = await _surveyRepository.GetAsync(input.SurveyId);
SurveyResponse? response = null;
if (CurrentUser.IsAuthenticated)
{
var responseQueryable = await _surveyResponseRepository.GetQueryableAsync();
response = await AsyncExecuter.FirstOrDefaultAsync(
responseQueryable.Where(r =>
r.SurveyId == input.SurveyId &&
(r.UserId == CurrentUser.Id || r.CreatorId == CurrentUser.Id))
);
}
if (response != null)
{
var answerQueryable = await _surveyAnswerRepository.GetQueryableAsync();
var existingAnswers = await AsyncExecuter.ToListAsync(
answerQueryable.Where(a => a.ResponseId == response.Id)
);
var existingAnswerMap = existingAnswers.ToDictionary(x => x.QuestionId);
foreach (var inputAnswer in input.Answers)
{
if (existingAnswerMap.TryGetValue(inputAnswer.QuestionId, out var existingAnswer))
{
existingAnswer.Value = inputAnswer.Value ?? string.Empty;
existingAnswer.QuestionType = inputAnswer.QuestionType;
await _surveyAnswerRepository.UpdateAsync(existingAnswer);
}
else
{
await _surveyAnswerRepository.InsertAsync(new SurveyAnswer(Guid.NewGuid())
{
ResponseId = response.Id,
QuestionId = inputAnswer.QuestionId,
QuestionType = inputAnswer.QuestionType,
Value = inputAnswer.Value ?? string.Empty
});
}
}
response.SubmissionTime = Clock.Now;
await _surveyResponseRepository.UpdateAsync(response);
return;
}
var newResponse = new SurveyResponse(Guid.NewGuid())
{
SurveyId = input.SurveyId,
UserId = survey.IsAnonymous ? null : CurrentUser.Id,
SubmissionTime = Clock.Now,
Answers = input.Answers.Select(a => new SurveyAnswer(Guid.NewGuid())
{
QuestionId = a.QuestionId,
QuestionType = a.QuestionType,
Value = a.Value ?? string.Empty
}).ToList()
};
await _surveyResponseRepository.InsertAsync(newResponse);
survey.Responses++;
await _surveyRepository.UpdateAsync(survey);
}
}

View file

@ -11970,6 +11970,12 @@
"tr": "Anketi Gönder",
"en": "Submit Survey"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SurveyModal.Update",
"tr": "Anketi Güncelle",
"en": "Update Survey"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationMap.OpenInGoogleMaps",

View file

@ -2913,17 +2913,16 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
DeleteCommand = DefaultDeleteCommand(nameof(TableNameEnum.Survey)),
DeleteFieldsDefaultValueJson = DefaultDeleteFieldsDefaultValueJson(),
PagerOptionJson = DefaultPagerOptionJson,
EditingOptionJson = DefaultEditingOptionJson(listFormName, 500, 500, true, true, true, true, false, true),
EditingOptionJson = DefaultEditingOptionJson(listFormName, 700, 500, true, true, true, true, false, true),
InsertFieldsDefaultValueJson = DefaultInsertFieldsDefaultValueJson(),
EditingFormJson = JsonSerializer.Serialize(new List<EditingFormDto>()
{
new() {
Order=1, ColCount=2, ColSpan=1, ItemType="group", Items =[
new EditingFormItemDto { Order = 1, DataField = "Title", ColSpan=2, IsRequired = true, EditorType2 = EditorTypes.dxTextBox },
Order=1, ColCount=3, ColSpan=1, ItemType="group", Items =[
new EditingFormItemDto { Order = 1, DataField = "Title", ColSpan=3, IsRequired = true, EditorType2 = EditorTypes.dxTextBox },
new EditingFormItemDto { Order = 2, DataField = "Deadline", ColSpan=1, IsRequired = true, EditorType2 = EditorTypes.dxDateBox },
new EditingFormItemDto { Order = 3, DataField = "Responses", ColSpan=1, EditorType2 = EditorTypes.dxNumberBox },
new EditingFormItemDto { Order = 4, DataField = "Status", ColSpan=1, IsRequired = true, EditorType2 = EditorTypes.dxSelectBox, EditorOptions=EditorOptionValues.ShowClearButton },
new EditingFormItemDto { Order = 5, DataField = "IsAnonymous", ColSpan=1, IsRequired = true, EditorType2 = EditorTypes.dxCheckBox },
new EditingFormItemDto { Order = 3, DataField = "Status", ColSpan=1, IsRequired = true, EditorType2 = EditorTypes.dxSelectBox, EditorOptions=EditorOptionValues.ShowClearButton },
new EditingFormItemDto { Order = 4, DataField = "IsAnonymous", ColSpan=1, IsRequired = true, EditorType2 = EditorTypes.dxCheckBox },
]}
}),
FormFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[]
@ -3109,7 +3108,7 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
DeleteCommand = DefaultDeleteCommand(nameof(TableNameEnum.SurveyQuestion)),
DeleteFieldsDefaultValueJson = DefaultDeleteFieldsDefaultValueJson(),
PagerOptionJson = DefaultPagerOptionJson,
EditingOptionJson = DefaultEditingOptionJson(listFormName, 500, 400, true, true, true, true, false, true),
EditingOptionJson = DefaultEditingOptionJson(listFormName, 500, 400, true, true, true, true, false, false),
InsertFieldsDefaultValueJson = DefaultInsertFieldsDefaultValueJson(),
EditingFormJson = JsonSerializer.Serialize(new List<EditingFormDto>()
{
@ -3280,7 +3279,7 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
DeleteCommand = DefaultDeleteCommand(nameof(TableNameEnum.SurveyResponse)),
DeleteFieldsDefaultValueJson = DefaultDeleteFieldsDefaultValueJson(),
PagerOptionJson = DefaultPagerOptionJson,
EditingOptionJson = DefaultEditingOptionJson(listFormName, 500, 400, true, true, true, true, false, true),
EditingOptionJson = DefaultEditingOptionJson(listFormName, 500, 400, true, true, true, true, false, false),
InsertFieldsDefaultValueJson = DefaultInsertFieldsDefaultValueJson(),
EditingFormJson = JsonSerializer.Serialize(new List<EditingFormDto>()
{

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Identity;
using Volo.Abp.Domain.Entities.Auditing;
using Volo.Abp.MultiTenancy;
@ -59,6 +58,13 @@ public class SurveyResponse : FullAuditedEntity<Guid>, IMultiTenant
public DateTime SubmissionTime { get; set; }
public ICollection<SurveyAnswer> Answers { get; set; }
public SurveyResponse(Guid id)
{
Id = id;
}
protected SurveyResponse() { }
}
public class SurveyAnswer : FullAuditedEntity<Guid>, IMultiTenant
@ -73,4 +79,11 @@ public class SurveyAnswer : FullAuditedEntity<Guid>, IMultiTenant
public string QuestionType { get; set; } // rating | multiple-choice | text | textarea | yes-no
public string Value { get; set; }
public SurveyAnswer(Guid id)
{
Id = id;
}
protected SurveyAnswer() { }
}

View file

@ -1351,7 +1351,7 @@
"Title": "Çalışan Memnuniyet Anketi 2024",
"Description": "Yıllık çalışan memnuniyeti ve bağlılık araştırması",
"Deadline": "2024-10-31T00:00:00",
"Responses": 45,
"Responses": 0,
"Status": "active",
"IsAnonymous": true
},
@ -1359,7 +1359,7 @@
"Title": "Eğitim İhtiyaç Analizi",
"Description": "2025 yılı eğitim planlaması için ihtiyaç tespiti",
"Deadline": "2024-11-15T00:00:00",
"Responses": 28,
"Responses": 0,
"Status": "active",
"IsAnonymous": false
},
@ -1367,7 +1367,7 @@
"Title": "Kafeterya Memnuniyet Anketi",
"Description": "Yemek kalitesi ve servis değerlendirmesi",
"Deadline": "2024-09-30T00:00:00",
"Responses": 62,
"Responses": 0,
"Status": "passive",
"IsAnonymous": true
}

View file

@ -89,6 +89,7 @@ export interface SurveyDto {
targetAudience: string[]
status: 'draft' | 'active' | 'closed'
isAnonymous: boolean
myResponse?: SurveyResponseDto
}
// Sosyal Duvar - Comment Interface

View file

@ -12,6 +12,20 @@ export class IntranetService {
},
{ apiName: this.apiName, ...config },
)
updateSurveyResponse = (
surveyId: string,
answers: { questionId: string; questionType: string; value: string }[],
config?: Partial<Config>,
) =>
apiService.fetchData<void>(
{
method: 'POST',
url: '/api/app/intranet/update-survey-response',
data: { surveyId, answers },
},
{ apiName: this.apiName, ...config },
)
}
export const intranetService = new IntranetService()

View file

@ -80,7 +80,21 @@ const IntranetDashboard: React.FC = () => {
setShowSurveyModal(true)
}
const handleSubmitSurvey = (answers: SurveyAnswerDto[]) => {
const handleSubmitSurvey = async (answers: SurveyAnswerDto[]) => {
if (!selectedSurvey) return
try {
await intranetService.updateSurveyResponse(
selectedSurvey.id,
answers.map((a) => ({
questionId: a.questionId,
questionType: a.questionType,
value: a.value !== undefined && a.value !== null ? String(a.value) : '',
})),
)
await fetchIntranetDashboard()
} catch (e) {
console.error('Survey submit error', e)
}
setShowSurveyModal(false)
setSelectedSurvey(null)
}

View file

@ -12,7 +12,19 @@ interface SurveyModalProps {
import { useLocalization } from '@/utils/hooks/useLocalization'
const SurveyModal: React.FC<SurveyModalProps> = ({ survey, onClose, onSubmit }) => {
const { translate } = useLocalization();
const [answers, setAnswers] = useState<{ [questionId: string]: any }>({})
const isUpdate = !!survey.myResponse
const [answers, setAnswers] = useState<{ [questionId: string]: any }>(() => {
if (survey.myResponse?.answers) {
return Object.fromEntries(
survey.myResponse.answers.map((a) => [
a.questionId,
a.questionType === 'rating' ? Number(a.value) : a.value,
])
)
}
return {}
})
const [errors, setErrors] = useState<{ [questionId: string]: string }>({})
const handleAnswerChange = (questionId: string, value: any) => {
@ -34,9 +46,13 @@ const SurveyModal: React.FC<SurveyModalProps> = ({ survey, onClose, onSubmit })
const newErrors: { [questionId: string]: string } = {}
survey.questions.forEach((question) => {
if (question.isRequired && (!answers[question.id] || answers[question.id] === '')) {
if (question.isRequired) {
const val = answers[question.id]
const isEmpty = val === undefined || val === null || val === '' || (question.type === 'rating' && Number(val) === 0)
if (isEmpty) {
newErrors[question.id] = translate('::App.Platform.Intranet.SurveyModal.RequiredField')
}
}
})
setErrors(newErrors)
@ -72,6 +88,22 @@ const SurveyModal: React.FC<SurveyModalProps> = ({ survey, onClose, onSubmit })
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{questionNumber}. {question.questionText} {question.isRequired && '*'}
</label>
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => handleAnswerChange(question.id, star)}
className={`text-2xl transition-colors ${
(answers[question.id] ?? 0) >= star
? 'text-yellow-400'
: 'text-gray-300 dark:text-gray-600 hover:text-yellow-300'
}`}
>
</button>
))}
</div>
{hasError && (
<p className="text-sm text-red-600 dark:text-red-400">{errors[question.id]}</p>
)}
@ -97,8 +129,8 @@ const SurveyModal: React.FC<SurveyModalProps> = ({ survey, onClose, onSubmit })
<input
type="radio"
name={`question-${question.id}`}
value={option.id}
checked={answers[question.id] === option.id}
value={option.text}
checked={answers[question.id] === option.text}
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
className="w-4 h-4 text-blue-600"
/>
@ -262,7 +294,9 @@ const SurveyModal: React.FC<SurveyModalProps> = ({ survey, onClose, onSubmit })
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
{translate('::App.Platform.Intranet.SurveyModal.Submit')}
{isUpdate
? translate('::App.Platform.Intranet.SurveyModal.Update')
: translate('::App.Platform.Intranet.SurveyModal.Submit')}
</button>
</div>
</form>

View file

@ -9,7 +9,6 @@ const TodayBirthdays: React.FC<{ employees: UserInfoViewModel[] }> = ({ employee
const today = dayjs()
const { translate } = useLocalization();
console.log('TodayBirthdays rendered with employees:', employees)
return (
<div className="bg-gradient-to-br from-pink-50 to-purple-50 dark:from-pink-900/20 dark:to-purple-900/20 rounded-lg shadow-sm border border-pink-200 dark:border-pink-800">
<div className="p-4 border-b border-pink-200 dark:border-pink-700">