chartManD commited on
Commit
136cbe4
·
1 Parent(s): 37a4e5f

Creacion de grupos para napping con sorting

Browse files
tecnicas/controllers/api_controller/rating_napping_controller.py CHANGED
@@ -12,6 +12,8 @@ class RatingNappingController:
12
  id=request.session["id_participation"]
13
  )
14
 
 
 
15
  try:
16
  with transaction.atomic():
17
  products_map = RatingNappingController.getProductsMap(
 
12
  id=request.session["id_participation"]
13
  )
14
 
15
+ print(data)
16
+
17
  try:
18
  with transaction.atomic():
19
  products_map = RatingNappingController.getProductsMap(
tecnicas/controllers/views_controller/session_management/details/details_napping_controller.py CHANGED
@@ -41,12 +41,16 @@ class DetallesNappingController(DetallesController):
41
  self.context["status"] = "Sesión con en curso"
42
 
43
  def controllPostResponse(self, request: HttpRequest, action: str):
 
44
  if action == "start_sin_modalidad":
45
  response = self.startNapping(request=request)
46
 
47
  elif action == "start_perfil_ultra_flash":
48
  response = self.startNapping(request=request)
49
 
 
 
 
50
  elif action == "delete_session":
51
  self.deleteSesorialSession()
52
  response = redirect(
@@ -118,32 +122,33 @@ class DetallesNappingController(DetallesController):
118
 
119
  def setWordFrequencies(self, ratings):
120
  from collections import Counter
121
-
122
  # Prefetch palabras to optimize queries
123
- ratings_with_words = ratings.prefetch_related('palabras').select_related('id_producto')
124
-
 
125
  # Dictionary to store word frequencies by product
126
  word_frequencies_by_product = defaultdict(Counter)
127
  all_words_set = set()
128
-
129
  for rating in ratings_with_words:
130
  producto_code = rating.id_producto.codigoProducto
131
  words = rating.palabras.all()
132
-
133
  for word in words:
134
  word_name = word.nombre_palabra
135
  word_frequencies_by_product[producto_code][word_name] += 1
136
  all_words_set.add(word_name)
137
-
138
  # Convert Counter objects to regular dicts and sort words alphabetically
139
  word_frequencies_dict = {
140
  product: dict(frequencies)
141
  for product, frequencies in word_frequencies_by_product.items()
142
  }
143
-
144
  # Sort all words alphabetically for consistent column ordering
145
  all_words_sorted = sorted(all_words_set)
146
-
147
  self.context["word_frequencies"] = word_frequencies_dict
148
  self.context["all_words"] = all_words_sorted
149
 
 
41
  self.context["status"] = "Sesión con en curso"
42
 
43
  def controllPostResponse(self, request: HttpRequest, action: str):
44
+ print(action)
45
  if action == "start_sin_modalidad":
46
  response = self.startNapping(request=request)
47
 
48
  elif action == "start_perfil_ultra_flash":
49
  response = self.startNapping(request=request)
50
 
51
+ elif action == "start_sorting":
52
+ response = self.startNapping(request=request)
53
+
54
  elif action == "delete_session":
55
  self.deleteSesorialSession()
56
  response = redirect(
 
122
 
123
  def setWordFrequencies(self, ratings):
124
  from collections import Counter
125
+
126
  # Prefetch palabras to optimize queries
127
+ ratings_with_words = ratings.prefetch_related(
128
+ 'palabras').select_related('id_producto')
129
+
130
  # Dictionary to store word frequencies by product
131
  word_frequencies_by_product = defaultdict(Counter)
132
  all_words_set = set()
133
+
134
  for rating in ratings_with_words:
135
  producto_code = rating.id_producto.codigoProducto
136
  words = rating.palabras.all()
137
+
138
  for word in words:
139
  word_name = word.nombre_palabra
140
  word_frequencies_by_product[producto_code][word_name] += 1
141
  all_words_set.add(word_name)
142
+
143
  # Convert Counter objects to regular dicts and sort words alphabetically
144
  word_frequencies_dict = {
145
  product: dict(frequencies)
146
  for product, frequencies in word_frequencies_by_product.items()
147
  }
148
+
149
  # Sort all words alphabetically for consistent column ordering
150
  all_words_sorted = sorted(all_words_set)
151
+
152
  self.context["word_frequencies"] = word_frequencies_dict
153
  self.context["all_words"] = all_words_sorted
154
 
tecnicas/controllers/views_controller/sessions_tester/tests_forms/test_napping_controller.py CHANGED
@@ -14,6 +14,7 @@ class TestNappingController(GenetalTestController):
14
  super().__init__(sensorial_session, user_tester)
15
  self.napping_test = "tecnicas/forms_tester/test_napping.html"
16
  self.napping_puf_test = "tecnicas/forms_tester/test_napping_puf.html"
 
17
 
18
  def controllGet(self, request: HttpRequest):
19
  technique = self.session.tecnica
@@ -34,9 +35,15 @@ class TestNappingController(GenetalTestController):
34
  if name_mode_activate == "sin modalidad":
35
  self.context["mode"] = "sin modalidad"
36
  return self.nappingTest(request)
 
37
  if name_mode_activate == "perfil ultra flash":
38
  self.context["mode"] = "perfil ultra flash"
39
  return self.nappingPufTest(request)
 
 
 
 
 
40
  else:
41
  return noValidTechnique(
42
  name_view=self.previus_directory,
@@ -76,6 +83,17 @@ class TestNappingController(GenetalTestController):
76
 
77
  return render(request, self.napping_puf_test, self.context)
78
 
 
 
 
 
 
 
 
 
 
 
 
79
  def setCoordinates(self):
80
  technique = self.session.tecnica
81
 
 
14
  super().__init__(sensorial_session, user_tester)
15
  self.napping_test = "tecnicas/forms_tester/test_napping.html"
16
  self.napping_puf_test = "tecnicas/forms_tester/test_napping_puf.html"
17
+ self.sort_direction = "tecnicas/forms_tester/test_napping_sort.html"
18
 
19
  def controllGet(self, request: HttpRequest):
20
  technique = self.session.tecnica
 
35
  if name_mode_activate == "sin modalidad":
36
  self.context["mode"] = "sin modalidad"
37
  return self.nappingTest(request)
38
+
39
  if name_mode_activate == "perfil ultra flash":
40
  self.context["mode"] = "perfil ultra flash"
41
  return self.nappingPufTest(request)
42
+
43
+ if name_mode_activate == "sorting":
44
+ self.context["mode"] = "sorting"
45
+ return self.nappingSort(request)
46
+
47
  else:
48
  return noValidTechnique(
49
  name_view=self.previus_directory,
 
83
 
84
  return render(request, self.napping_puf_test, self.context)
85
 
86
+ def nappingSort(self, request: HttpRequest):
87
+ self.context["session"] = self.session
88
+ technique = self.session.tecnica
89
+
90
+ products_in_technique = Producto.objects.filter(id_tecnica=technique)
91
+ self.context["products"] = products_in_technique
92
+
93
+ self.setCoordinates()
94
+
95
+ return render(request, self.sort_direction, self.context)
96
+
97
  def setCoordinates(self):
98
  technique = self.session.tecnica
99
 
tecnicas/static/js/span-notification.js CHANGED
@@ -1,5 +1,6 @@
1
  function spanNotifaction(messageError, isError = true, time = 4500) {
2
  const span = document.createElement("span");
 
3
  span.textContent = messageError;
4
 
5
  const div = document.createElement("div");
 
1
  function spanNotifaction(messageError, isError = true, time = 4500) {
2
  const span = document.createElement("span");
3
+ span.classList.add("text-xl", "font-bold", "text-white");
4
  span.textContent = messageError;
5
 
6
  const div = document.createElement("div");
tecnicas/static/js/test-napping-plane.js CHANGED
@@ -204,7 +204,7 @@ window.saveData = async function (isFinishSession = false) {
204
  data.push(objData);
205
  })
206
 
207
- const URL = "/cata/testers/api/rating-napping/no-mode"
208
  const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
209
 
210
  try {
 
204
  data.push(objData);
205
  })
206
 
207
+ const URL = "/cata/testers/api/rating-napping"
208
  const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
209
 
210
  try {
tecnicas/static/js/test-napping-sort.js ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Napping Sort Mode - Three Phase Implementation
2
+ // Phase 1: Place products on plane
3
+ // Phase 2: Create groups by selecting points
4
+ // Phase 3: Describe groups with words
5
+
6
+ // Store groups: { "group-1": ["CODE1", "CODE2"], "group-2": ["CODE3"] }
7
+ const productGroups = {};
8
+
9
+ // Store group words: { "group-1": ["word1", "word2"], "group-2": ["word3"] }
10
+ const groupWords = {};
11
+
12
+ // Track which group each product belongs to: { "CODE1": "group-1", "CODE2": "group-1" }
13
+ const productToGroup = {};
14
+
15
+ // Selected points for creating a group
16
+ let selectedPoints = [];
17
+
18
+ // Current group counter for generating IDs
19
+ let groupCounter = 1;
20
+
21
+ // Current phase (1, 2, or 3)
22
+ let currentPhase = 1;
23
+
24
+ // Current group being described
25
+ let currentGroupId = null;
26
+
27
+ // Only initialize if in sort mode
28
+ const modeElementSort = document.querySelector('[data-mode]');
29
+ const isSortMode = modeElementSort && modeElementSort.dataset.mode.toLowerCase() === 'sorting';
30
+
31
+ if (isSortMode) {
32
+ initSortMode();
33
+ }
34
+
35
+ function initSortMode() {
36
+ const continueGroupingBtn = document.getElementById('continue-grouping');
37
+ const continueDescriptionBtn = document.getElementById('continue-description');
38
+ const createGroupBtn = document.getElementById('create-group-btn');
39
+ const dissolveGroupBtn = document.getElementById('dissolve-group-btn');
40
+ const groupControls = document.getElementById('group-controls');
41
+ const questionSaveBtn = document.getElementById('question-save');
42
+ const groupWordDialog = document.getElementById('group-word-dialog');
43
+ const groupWordForm = document.getElementById('group-word-form');
44
+ const groupWordInput = document.querySelector('.cts-input-list-word');
45
+ const groupWordList = document.getElementById('group-word-list');
46
+ const dialogGroupId = document.getElementById('dialog-group-id');
47
+
48
+ // Hide question save button initially
49
+ questionSaveBtn.classList.add('hidden');
50
+
51
+ // Check if there are existing groups from backend (TODO: implement backend data loading)
52
+ // For now, start in Phase 1
53
+
54
+ // Phase 1: Product Placement
55
+ // Show continue to grouping button when all products are placed
56
+ setInterval(() => {
57
+ if (currentPhase === 1) {
58
+ const placedCount = Object.keys(window.placedPoints).length;
59
+ const totalProducts = document.querySelectorAll('.item-product').length;
60
+
61
+ if (placedCount === totalProducts && placedCount > 0) {
62
+ continueGroupingBtn.classList.remove('hidden');
63
+ } else {
64
+ continueGroupingBtn.classList.add('hidden');
65
+ }
66
+ }
67
+ }, 500);
68
+
69
+ // Transition to Phase 2: Grouping
70
+ continueGroupingBtn.addEventListener('click', () => {
71
+ const placedCount = Object.keys(window.placedPoints).length;
72
+ const totalProducts = document.querySelectorAll('.item-product').length;
73
+
74
+ if (placedCount !== totalProducts) {
75
+ spanNotifaction("Por favor, coloca todos los productos antes de continuar.");
76
+ return;
77
+ }
78
+
79
+ startGroupingPhase();
80
+ });
81
+
82
+ function startGroupingPhase() {
83
+ currentPhase = 2;
84
+ window.isPlacementActive = false;
85
+ continueGroupingBtn.classList.add('hidden');
86
+ groupControls.classList.remove('hidden');
87
+ continueDescriptionBtn.classList.remove('hidden');
88
+
89
+ const plane = document.getElementById('napping-plane');
90
+ plane.classList.remove('cursor-crosshair');
91
+ plane.classList.add('cursor-default');
92
+
93
+ // Remove selection from products
94
+ document.querySelectorAll('.item-product').forEach(p => {
95
+ p.classList.remove('ring-4', 'ring-primary');
96
+ });
97
+
98
+ spanNotifaction("Fase de agrupación: Selecciona puntos y crea grupos.", false);
99
+
100
+ // Auto-save points when transitioning to Phase 2
101
+ window.saveData(false);
102
+
103
+ // Enable point selection
104
+ enablePointSelection();
105
+ }
106
+
107
+ function enablePointSelection() {
108
+ // Add click handler to points for selection
109
+ document.getElementById('napping-plane').addEventListener('click', (e) => {
110
+ if (currentPhase !== 2) return;
111
+
112
+ const point = e.target.closest('.data-point');
113
+ if (point) {
114
+ e.stopPropagation();
115
+ togglePointSelection(point.dataset.code);
116
+ }
117
+ });
118
+ }
119
+
120
+ function togglePointSelection(code) {
121
+ // Check if point is already in a group
122
+ if (productToGroup[code]) {
123
+ spanNotifaction(`El producto ${code} ya pertenece al grupo ${productToGroup[code]}`);
124
+ return;
125
+ }
126
+
127
+ const point = document.getElementById(`point-${code}`);
128
+ const index = selectedPoints.indexOf(code);
129
+
130
+ if (index > -1) {
131
+ // Deselect
132
+ selectedPoints.splice(index, 1);
133
+ point.classList.remove('ring-4', 'ring-blue-500');
134
+ point.classList.add('bg-red-600');
135
+ point.classList.remove('bg-blue-600');
136
+ } else {
137
+ // Select
138
+ selectedPoints.push(code);
139
+ point.classList.add('ring-4', 'ring-blue-500');
140
+ point.classList.remove('bg-red-600');
141
+ point.classList.add('bg-blue-600');
142
+ }
143
+ }
144
+
145
+ // Create Group
146
+ createGroupBtn.addEventListener('click', () => {
147
+ if (selectedPoints.length === 0) {
148
+ spanNotifaction("Selecciona al menos un punto para crear un grupo");
149
+ return;
150
+ }
151
+
152
+ const groupId = `group-${groupCounter++}`;
153
+ productGroups[groupId] = [...selectedPoints];
154
+ groupWords[groupId] = [];
155
+
156
+ // Update product to group mapping
157
+ selectedPoints.forEach(code => {
158
+ productToGroup[code] = groupId;
159
+ });
160
+
161
+ // Visual update: change color of grouped points
162
+ const colors = ['bg-green-600', 'bg-purple-600', 'bg-yellow-600', 'bg-pink-600', 'bg-indigo-600'];
163
+ const colorIndex = (groupCounter - 2) % colors.length;
164
+
165
+ selectedPoints.forEach(code => {
166
+ const point = document.getElementById(`point-${code}`);
167
+ point.classList.remove('bg-blue-600', 'ring-4', 'ring-blue-500');
168
+ point.classList.add(colors[colorIndex]);
169
+ });
170
+
171
+ // Add group to display
172
+ addGroupToDisplay(groupId, selectedPoints, colors[colorIndex]);
173
+
174
+ // Clear selection
175
+ selectedPoints = [];
176
+
177
+ spanNotifaction(`Grupo "${groupId}" creado con éxito`, false);
178
+ });
179
+
180
+ function addGroupToDisplay(groupId, products, colorClass) {
181
+ const groupsDisplay = document.getElementById('groups-display');
182
+
183
+ const groupBadge = document.createElement('div');
184
+ groupBadge.id = `display-${groupId}`;
185
+ groupBadge.className = `badge badge-lg gap-2 p-4 ${colorClass} text-white cursor-pointer font-semibold`;
186
+ groupBadge.innerHTML = `
187
+ [ ${products.join(', ')} ]
188
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-4 h-4 stroke-current dissolve-group-icon" data-group-id="${groupId}">
189
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
190
+ </svg>
191
+ `;
192
+
193
+ groupsDisplay.appendChild(groupBadge);
194
+
195
+ // Add dissolve handler
196
+ groupBadge.querySelector('.dissolve-group-icon').addEventListener('click', (e) => {
197
+ e.stopPropagation();
198
+ dissolveGroup(groupId);
199
+ });
200
+
201
+ // Add click handler for Phase 3 (describe group)
202
+ groupBadge.addEventListener('click', () => {
203
+ if (currentPhase === 3) {
204
+ openGroupWordDialog(groupId);
205
+ }
206
+ });
207
+ }
208
+
209
+ function dissolveGroup(groupId) {
210
+ if (currentPhase === 3) {
211
+ spanNotifaction("No puedes disolver un grupo en la fase de descripción");
212
+ return;
213
+ }
214
+
215
+ if (groupWords[groupId] && groupWords[groupId].length > 0) {
216
+ spanNotifaction("No puedes disolver un grupo que ya tiene palabras descriptivas");
217
+ return;
218
+ }
219
+
220
+ // Remove from product to group mapping
221
+ productGroups[groupId].forEach(code => {
222
+ delete productToGroup[code];
223
+ const point = document.getElementById(`point-${code}`);
224
+ point.classList.remove('bg-green-600', 'bg-purple-600', 'bg-yellow-600', 'bg-pink-600', 'bg-indigo-600');
225
+ point.classList.add('bg-red-600');
226
+ });
227
+
228
+ // Remove group
229
+ delete productGroups[groupId];
230
+ delete groupWords[groupId];
231
+
232
+ // Remove from display
233
+ const displayElement = document.getElementById(`display-${groupId}`);
234
+ if (displayElement) {
235
+ displayElement.remove();
236
+ }
237
+
238
+ spanNotifaction(`Grupo "${groupId}" disuelto`, false);
239
+ }
240
+
241
+ // Dissolve Group button (dissolves last created group or selected group)
242
+ dissolveGroupBtn.addEventListener('click', () => {
243
+ const groupIds = Object.keys(productGroups);
244
+ if (groupIds.length === 0) {
245
+ spanNotifaction("No hay grupos para disolver");
246
+ return;
247
+ }
248
+
249
+ // Dissolve the last group
250
+ const lastGroupId = groupIds[groupIds.length - 1];
251
+ dissolveGroup(lastGroupId);
252
+ });
253
+
254
+ // Transition to Phase 3: Description
255
+ continueDescriptionBtn.addEventListener('click', () => {
256
+ const groupIds = Object.keys(productGroups);
257
+ if (groupIds.length === 0) {
258
+ spanNotifaction("Crea al menos un grupo para continuar");
259
+ return;
260
+ }
261
+
262
+ // Check all products are in groups
263
+ const totalProducts = document.querySelectorAll('.item-product').length;
264
+ const groupedProducts = Object.keys(productToGroup).length;
265
+
266
+ if (groupedProducts !== totalProducts) {
267
+ spanNotifaction("Todos los productos deben estar asignados a un grupo");
268
+ return;
269
+ }
270
+
271
+ startDescriptionPhase();
272
+ });
273
+
274
+ function startDescriptionPhase() {
275
+ currentPhase = 3;
276
+ continueDescriptionBtn.classList.add('hidden');
277
+
278
+ createGroupBtn.classList.add('hidden');
279
+ dissolveGroupBtn.classList.add('hidden');
280
+
281
+ questionSaveBtn.classList.remove('hidden');
282
+
283
+ const iconsDisolveGroup = document.querySelectorAll('.dissolve-group-icon');
284
+ for (let index = 0; index < iconsDisolveGroup.length; index++) {
285
+ const icon = iconsDisolveGroup.item(index);
286
+ icon.remove();
287
+ }
288
+
289
+
290
+ spanNotifaction("Fase de descripción: Haz clic en un grupo para agregar palabras.", false);
291
+
292
+ // Auto-save groups when transitioning to Phase 3
293
+ window.saveData(false);
294
+ }
295
+
296
+ // Group Word Dialog Functions
297
+ function openGroupWordDialog(groupId) {
298
+ currentGroupId = groupId;
299
+ dialogGroupId.innerText = groupId;
300
+ renderGroupWordList();
301
+ groupWordDialog.showModal();
302
+ }
303
+
304
+ function renderGroupWordList() {
305
+ groupWordList.innerHTML = '';
306
+ const words = groupWords[currentGroupId] || [];
307
+
308
+ words.forEach((word, index) => {
309
+ const badge = document.createElement('div');
310
+ badge.className = 'badge badge-secondary gap-2 p-3';
311
+ badge.innerHTML = `
312
+ ${word}
313
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-4 h-4 stroke-current cursor-pointer remove-word" data-index="${index}"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
314
+ `;
315
+
316
+ badge.querySelector('.remove-word').addEventListener('click', () => {
317
+ removeGroupWord(index);
318
+ });
319
+
320
+ groupWordList.appendChild(badge);
321
+ });
322
+
323
+ // Update group display with word count
324
+ updateGroupDisplayWithWords(currentGroupId);
325
+ }
326
+
327
+ function addGroupWord(word) {
328
+ if (!groupWords[currentGroupId]) {
329
+ groupWords[currentGroupId] = [];
330
+ }
331
+
332
+ if (groupWords[currentGroupId].includes(word)) {
333
+ spanNotifaction("Palabra duplicada");
334
+ return;
335
+ }
336
+
337
+ groupWords[currentGroupId].push(word);
338
+ renderGroupWordList();
339
+ }
340
+
341
+ function removeGroupWord(index) {
342
+ if (groupWords[currentGroupId]) {
343
+ groupWords[currentGroupId].splice(index, 1);
344
+ renderGroupWordList();
345
+ }
346
+ }
347
+
348
+ groupWordForm.addEventListener('submit', (e) => {
349
+ e.preventDefault();
350
+ const word = groupWordInput.value.trim();
351
+ if (word) {
352
+ addGroupWord(word);
353
+ groupWordInput.value = '';
354
+ groupWordInput.focus();
355
+ }
356
+ });
357
+
358
+ function updateGroupDisplayWithWords(groupId) {
359
+ const displayElement = document.getElementById(`display-${groupId}`);
360
+ if (!displayElement) return;
361
+
362
+ const words = groupWords[groupId] || [];
363
+ const products = productGroups[groupId] || [];
364
+
365
+ // Add tooltip with words on hover
366
+ if (words.length > 0) {
367
+ let tooltip = displayElement.querySelector('.group-tooltip');
368
+ if (!tooltip) {
369
+ tooltip = document.createElement('div');
370
+ tooltip.className = 'group-tooltip absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-800 text-white text-xs rounded z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none hidden';
371
+ displayElement.classList.add('group', 'relative');
372
+ displayElement.appendChild(tooltip);
373
+ }
374
+
375
+ const wordBadges = words.map(w => `<span class="inline-block px-2 py-1 bg-yellow-600 text-white rounded text-xs">${w}</span>`).join('');
376
+ tooltip.innerHTML = `
377
+ <strong>${groupId}</strong>
378
+ <div class="mt-2 pt-2 border-t border-gray-600" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; max-width: 300px;">
379
+ ${wordBadges}
380
+ </div>
381
+ `;
382
+ tooltip.style.maxWidth = '320px';
383
+ tooltip.style.whiteSpace = 'normal';
384
+ tooltip.classList.remove('hidden');
385
+ }
386
+ }
387
+
388
+ // Set up callbacks to extend the base saveData function
389
+ window.beforeSaveData = function (isFinishSession = false) {
390
+ if (isFinishSession) {
391
+ // Validate all products are placed
392
+ const totalProducts = document.querySelectorAll('.item-product').length;
393
+ const placedCount = Object.keys(window.placedPoints).length;
394
+
395
+ if (placedCount !== totalProducts) {
396
+ spanNotifaction("Por favor, coloca todos los productos antes de finalizar la sesión");
397
+ return false;
398
+ }
399
+
400
+ // Validate all products are in groups
401
+ const groupedProducts = Object.keys(productToGroup).length;
402
+ if (groupedProducts !== totalProducts) {
403
+ spanNotifaction("Todos los productos deben estar asignados a un grupo");
404
+ return false;
405
+ }
406
+
407
+ // Validate each group has at least one product (already guaranteed by creation logic)
408
+ const groupIds = Object.keys(productGroups);
409
+ if (groupIds.length === 0) {
410
+ spanNotifaction("Debe existir al menos un grupo");
411
+ return false;
412
+ }
413
+
414
+ // Validate each group has at least one word
415
+ for (const groupId of groupIds) {
416
+ const words = groupWords[groupId] || [];
417
+ if (words.length < 1) {
418
+ spanNotifaction(`El grupo ${groupId} debe tener al menos una palabra descriptiva`);
419
+ return false;
420
+ }
421
+ }
422
+ }
423
+ return true;
424
+ };
425
+
426
+ // Data extension callback - adds groups and words to save data
427
+ window.getExtraDataForSave = function (code) {
428
+ const groupId = productToGroup[code];
429
+ const group = groupId ? {
430
+ groupId: groupId,
431
+ groupWords: groupWords[groupId] || []
432
+ } : null;
433
+
434
+ return {
435
+ group: group
436
+ };
437
+ };
438
+ }
tecnicas/templates/tecnicas/components/dialog-nap-sort.html ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <dialog id="group-word-dialog" class="modal">
2
+ <div class="modal-box max-w-2xl">
3
+ <h3 class="font-bold text-lg">Describir Grupo: <span id="dialog-group-id"></span></h3>
4
+ <p class="py-2 text-sm text-gray-600">Agrega palabras para describir este grupo de productos</p>
5
+
6
+ <form id="group-word-form" class="space-y-4">
7
+ <div class="form-control">
8
+ <label class="label">
9
+ <span class="label-text">Palabra</span>
10
+ </label>
11
+ <input type="text" class="cts-input-list-word input input-bordered w-full"
12
+ placeholder="Escribe una palabra..." maxlength="50" required>
13
+ </div>
14
+
15
+ <div class="flex justify-end">
16
+ <button type="submit" class="cts-btn-general cts-btn-primary btn-push">
17
+ Agregar Palabra
18
+ </button>
19
+ </div>
20
+ </form>
21
+
22
+ <div class="mt-4">
23
+ <h4 class="font-semibold mb-2">Palabras agregadas:</h4>
24
+ <div id="group-word-list" class="flex flex-wrap gap-2">
25
+ <!-- Words will be added here dynamically -->
26
+ </div>
27
+ </div>
28
+
29
+ <div class="modal-action">
30
+ <form method="dialog">
31
+ <button class="cts-btn-general cts-btn-secondary btn-push">Cerrar</button>
32
+ </form>
33
+ </div>
34
+ </div>
35
+ </dialog>
tecnicas/templates/tecnicas/forms_tester/test_napping_sort.html ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'tecnicas/layouts/base.html' %}
2
+
3
+ {% load static %}
4
+
5
+ {% block title %}Napping{% endblock %}
6
+
7
+ {% block content %}
8
+ <article class="cts-container-main">
9
+ <article class="cts-wrap-content text-black max-w-4xl">
10
+ <header class="text-center flex-row w-full items-stretch flex justify-around flex-wrap gap-2">
11
+ <h1 class="rounded font-bold text-2xl bg-surface-ligt p-4 flex-1">
12
+ Sesión usando <br>técnica
13
+ <span class="uppercase">{{ session.tecnica.tipo_tecnica }}</span>
14
+ </h1>
15
+ <button class="cts-btn-general cts-btn-error btn-push" onclick="exit_sesion('form-actions')">
16
+ Salir de la sesión
17
+ </button>
18
+ </header>
19
+
20
+ <article class="hidden">
21
+ <form id="form-finish-session"
22
+ action="{% url 'cata_system:catador_init_session' code_sesion=session.codigo_sesion %}" method="post"
23
+ class="form-actions">
24
+ {% csrf_token %}
25
+ <input type="hidden" name="action" value="finish_session" class="action-input">
26
+ </form>
27
+ </article>
28
+
29
+ <section class="hidden">
30
+ <input type="hidden" value="{{ session.tecnica.id }}" name="id-tecnica" class="ct-input-id-tech">
31
+ </section>
32
+
33
+ {% if error %}
34
+ {% include "../components/error-message.html" with message=error %}
35
+ {% endif %}
36
+
37
+ <article class="rounded flex flex-col gap-4">
38
+ <section class="flex items-center justify-center flex-wrap gap-4 bg-surface-ligt p-2 rounded-lg">
39
+ <p class="text-xl font-bold text-center capitalize" data-mode="{{ mode }}">
40
+ Modalidad: {% if mode != "sin modalidad" %}{{ mode }} {% else %} Napping {% endif %}
41
+ </p>
42
+ </section>
43
+ </article>
44
+
45
+ <article class="container-rating-word p-2 py-6 space-y-6 bg-surface-ligt rounded min-w-3xl">
46
+ <section class="flex items-center justify-around flex-wrap gap-4 bg-surface-ligt p-2 rounded-lg">
47
+ <h2 class="text-xl font-bold">Productos en la sesión</h2>
48
+ <div class="flex flex-col items-center justify-center flex-wrap">
49
+ <p class="text-lg font-bold text-center">Instrucciones</p>
50
+ <p class="text-lg font-normal text-center">
51
+ {{ session.tecnica.instrucciones }}
52
+ </p>
53
+ </div>
54
+ </section>
55
+
56
+ <section class="flex max-sm:flex-col gap-2 justify-around">
57
+ <article id="items" class="original-products flex gap-4 flex-wrap justify-center"
58
+ data-original-products="original">
59
+ {% for product in products %}
60
+ <div class="item-product bg-btn-secondary text-black font-bold px-4 py-2 rounded cursor-grab transition-all"
61
+ data-id-product="{{ product.id }}" data-code="{{ product.codigoProducto }}">
62
+ {{ product.codigoProducto }}
63
+ </div>
64
+ {% endfor %}
65
+ </article>
66
+
67
+ <article class="flex gap-2">
68
+ <button id="continue-grouping"
69
+ class="cts-btn-general-compress py-1 px-4 cts-btn-primary btn-push hidden">
70
+ Continuar a Agrupación
71
+ </button>
72
+ <button id="continue-description"
73
+ class="cts-btn-general-compress py-1 px-4 cts-btn-primary btn-push hidden">
74
+ Continuar a Descripción
75
+ </button>
76
+ </article>
77
+ </section>
78
+
79
+ <!-- Phase 2: Group Creation Controls (Hidden initially, shown in Sort mode) -->
80
+ <section id="group-controls" class="hidden space-y-2">
81
+ <div class="flex gap-4 justify-center items-center flex-wrap">
82
+ <button id="create-group-btn" class="cts-btn-general-compress py-1 px-4 cts-btn-primary btn-push">
83
+ Crear Grupo
84
+ </button>
85
+ <button id="dissolve-group-btn" class="cts-btn-general-compress py-1 px-4 cts-btn-error btn-push">
86
+ Disolver Grupo
87
+ </button>
88
+ </div>
89
+
90
+ <!-- Display existing groups -->
91
+ <div id="groups-display" class="flex gap-3 flex-wrap justify-center"></div>
92
+ </section>
93
+
94
+ <!-- Cartesian Plane Container -->
95
+ <div class="flex justify-center w-full">
96
+ <div class="relative w-full max-w-[800px] aspect-[3/2] bg-white border-2 border-gray-400 shadow-inner cursor-crosshair"
97
+ id="napping-plane"
98
+ style="background-image: linear-gradient(#e5e7eb 1px, transparent 1px), linear-gradient(90deg, #e5e7eb 1px, transparent 1px); background-size: 20px 20px;">
99
+
100
+ <span class="absolute bottom-1 right-2 text-xs text-gray-500 font-bold">60cm (X)</span>
101
+ <span class="absolute top-2 left-1 text-xs text-gray-500 font-bold">40cm (Y)</span>
102
+ <span class="absolute bottom-1 left-1 text-xs text-gray-500 font-bold">0</span>
103
+
104
+ {% for point in data_points %}
105
+ <div id="point-{{ point.code }}"
106
+ class="data-point absolute w-4 h-4 bg-red-600 rounded-full transform -translate-x-1/2 -translate-y-1/2 cursor-pointer border-2 border-white shadow-md group"
107
+ data-px="{{ point.px }}" data-py="{{ point.py }}" data-code="{{ point.code }}"
108
+ data-id-product="{{ point.id_product }}">
109
+ <div
110
+ class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-800 text-white text-xs rounded whitespace-nowrap z-10 hidden group-hover:block">
111
+ <strong>{{ point.code }}</strong><br>
112
+ X: {{ point.px }}<br>
113
+ Y: {{ point.py }}
114
+ </div>
115
+ <span
116
+ class="absolute top-4 left-1/2 transform -translate-x-1/2 text-xs font-bold text-gray-700 pointer-events-none">
117
+ {{ point.code }}
118
+ </span>
119
+ </div>
120
+ {% endfor %}
121
+ </div>
122
+ </div>
123
+
124
+ <section role="alert" class="alert alert-info">
125
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
126
+ class="h-6 w-6 shrink-0 stroke-current">
127
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
128
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 0 0118 0z"></path>
129
+ </svg>
130
+ <span class="text-lg">
131
+ Para guardar los datos sin finalizar su sesión use el botón
132
+ <b>"Guardar progreso"</b>
133
+ </span>
134
+ </section>
135
+
136
+ <section role="alert" class="alert alert-warning">
137
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
138
+ viewBox="0 0 24 24">
139
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
140
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
141
+ </svg>
142
+ <div class="flex-col">
143
+ <span class="text-lg block">
144
+ Si usas el botón <b>"Salir de la sesión"</b> asegúrese de guardar el progreso de lo contrario
145
+ perderás todo lo que no hayas guardado antes
146
+ </span>
147
+ <span class="text-lg block">
148
+ Con el botón <b>"Finalizar"</b>, se guardan las posiciones, sales de la sesión y no
149
+ podrás ingresar otra vez
150
+ </span>
151
+ </div>
152
+ </section>
153
+
154
+ <section class="flex justify-end gap-4 max-sm:flex-col">
155
+ <button id="save-progress" class="cts-btn-general cts-btn-primary btn-push">
156
+ Guardar progreso
157
+ </button>
158
+ <div class="flex gap-2">
159
+ <button id="question-save" class="cts-btn-general cts-btn-secondary btn-push flex-1">
160
+ ¿Finalizar sesión?
161
+ </button>
162
+ <button id="finish-session" class="cts-btn-general cts-btn-tertiary btn-push hidden flex-1"
163
+ onclick="window.finishSession()">
164
+ Finalizar
165
+ </button>
166
+ <button id="cancel-save" class="cts-btn-general cts-btn-error btn-push hidden flex-1">
167
+ Cancelar
168
+ </button>
169
+ </div>
170
+ <span id="loading-data-save" class="loading loading-spinner loading-xl text-accent hidden"></span>
171
+ </section>
172
+ </article>
173
+
174
+ {% include "../components/dialog-nap-sort.html" %}
175
+
176
+ {% include "../components/toast-container.html" %}
177
+ </article>
178
+ </article>
179
+ {% endblock %}
180
+
181
+ {% block extra_js %}
182
+ <script src="{% static 'js/actions-form.js' %}"></script>
183
+ <script src="{% static 'js/test-napping-plane.js' %}"></script>
184
+ <script src="{% static 'js/test-napping-sort.js' %}"></script>
185
+ {% endblock %}
tecnicas/urls.py CHANGED
@@ -156,7 +156,7 @@ urlpatterns = [
156
  views.ratingSort,
157
  name="api_rating_sort"),
158
 
159
- path("testers/api/rating-napping/no-mode",
160
- views.ratingNappingNoMode,
161
- name="api_rating_napping_no_mode"),
162
  ]
 
156
  views.ratingSort,
157
  name="api_rating_sort"),
158
 
159
+ path("testers/api/rating-napping",
160
+ views.ratingNapping,
161
+ name="api_rating_napping"),
162
  ]
tecnicas/views/__init__.py CHANGED
@@ -29,7 +29,7 @@ from .apis.api_list_words_pf import apiListWordsPF
29
  from .apis.rating_word_scales import ratingWordScales
30
  from .apis.rating_word_cata import ratingWordCata
31
  from .apis.rating_sort import ratingSort
32
- from .apis.rating_napping import ratingNappingNoMode
33
 
34
  from .tester_forms.init_tester_form import initTesterForm
35
  from .tester_forms.panel_main_tester import mainPanelTester
 
29
  from .apis.rating_word_scales import ratingWordScales
30
  from .apis.rating_word_cata import ratingWordCata
31
  from .apis.rating_sort import ratingSort
32
+ from .apis.rating_napping import ratingNapping
33
 
34
  from .tester_forms.init_tester_form import initTesterForm
35
  from .tester_forms.panel_main_tester import mainPanelTester
tecnicas/views/apis/rating_napping.py CHANGED
@@ -3,7 +3,7 @@ from tecnicas.controllers import RatingNappingController
3
  import json
4
 
5
 
6
- def ratingNappingNoMode(req: HttpRequest):
7
  if req.method == "POST":
8
  try:
9
  data = json.loads(req.body.decode("utf-8"))
 
3
  import json
4
 
5
 
6
+ def ratingNapping(req: HttpRequest):
7
  if req.method == "POST":
8
  try:
9
  data = json.loads(req.body.decode("utf-8"))