collavre_plan 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Rakefile +2 -0
- data/app/components/collavre/plans_timeline_component.html.erb +14 -0
- data/app/components/collavre/plans_timeline_component.rb +53 -0
- data/app/controllers/collavre_plan/application_controller.rb +4 -0
- data/app/controllers/collavre_plan/creative_plans_controller.rb +69 -0
- data/app/controllers/collavre_plan/plans_controller.rb +170 -0
- data/app/javascript/collavre_plan.js +8 -0
- data/app/javascript/controllers/creatives/set_plan_modal_controller.js +124 -0
- data/app/javascript/controllers/index.js +6 -0
- data/app/javascript/modules/plans_menu.js +50 -0
- data/app/javascript/modules/plans_timeline.js +411 -0
- data/app/models/collavre/plan.rb +43 -0
- data/app/services/collavre/creatives/plan_tagger.rb +64 -0
- data/app/views/collavre/creatives/_set_plan_modal.html.erb +32 -0
- data/app/views/collavre/labels/_plan_extra.html.erb +14 -0
- data/app/views/collavre/labels/_plan_suffix.html.erb +3 -0
- data/app/views/collavre_plan/creatives/_set_plan_button.html.erb +1 -0
- data/app/views/collavre_plan/shared/navigation/_mobile_plans_button.html.erb +1 -0
- data/app/views/collavre_plan/shared/navigation/_panels.html.erb +3 -0
- data/app/views/collavre_plan/shared/navigation/_plans_button.html.erb +3 -0
- data/config/locales/plans.en.yml +16 -0
- data/config/locales/plans.ko.yml +23 -0
- data/config/routes.rb +4 -0
- data/lib/collavre_plan/engine.rb +87 -0
- data/lib/collavre_plan/version.rb +3 -0
- data/lib/collavre_plan.rb +5 -0
- metadata +96 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
let plansTimelineScriptInitialized = false;
|
|
2
|
+
|
|
3
|
+
if (!plansTimelineScriptInitialized) {
|
|
4
|
+
plansTimelineScriptInitialized = true;
|
|
5
|
+
|
|
6
|
+
function initPlansTimeline(container) {
|
|
7
|
+
if (!container || container.dataset.initialized) return;
|
|
8
|
+
container.dataset.initialized = 'true';
|
|
9
|
+
|
|
10
|
+
var plans = [];
|
|
11
|
+
try { plans = JSON.parse(container.dataset.plans || '[]'); } catch (e) { }
|
|
12
|
+
plans = plans.map(function (p) {
|
|
13
|
+
if (p.start_date) {
|
|
14
|
+
p.start_date = new Date(p.start_date);
|
|
15
|
+
}
|
|
16
|
+
p.created_at = new Date(p.created_at);
|
|
17
|
+
p.target_date = new Date(p.target_date);
|
|
18
|
+
return p;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
var dayWidth = 80;
|
|
22
|
+
var rowHeight = 26;
|
|
23
|
+
var startDate = new Date(container.dataset.startDate || new Date());
|
|
24
|
+
var endDate = new Date(container.dataset.endDate || new Date());
|
|
25
|
+
container.dataset.lastLoadedDate = new Date().toISOString().slice(0, 10);
|
|
26
|
+
|
|
27
|
+
var scroll = document.createElement('div');
|
|
28
|
+
scroll.className = 'timeline-scroll';
|
|
29
|
+
container.appendChild(scroll);
|
|
30
|
+
|
|
31
|
+
function dayDiff(d1, d2) {
|
|
32
|
+
return Math.round((d1 - d2) / 86400000);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createDay(date) {
|
|
36
|
+
var el = document.createElement('div');
|
|
37
|
+
el.className = 'timeline-day';
|
|
38
|
+
el.dataset.date = date.toISOString().slice(0, 10);
|
|
39
|
+
el.innerHTML = '<div class="day-label">' + (date.getMonth() + 1) + '/' + date.getDate() + '</div>';
|
|
40
|
+
return el;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderDays(from, to, prepend) {
|
|
44
|
+
var date = new Date(from);
|
|
45
|
+
if (prepend) {
|
|
46
|
+
var days = [];
|
|
47
|
+
while (date <= to) {
|
|
48
|
+
days.push(createDay(new Date(date)));
|
|
49
|
+
date.setDate(date.getDate() + 1);
|
|
50
|
+
}
|
|
51
|
+
for (var i = days.length - 1; i >= 0; i--) {
|
|
52
|
+
scroll.insertBefore(days[i], scroll.firstChild);
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
while (date <= to) {
|
|
56
|
+
var el = createDay(new Date(date));
|
|
57
|
+
scroll.appendChild(el);
|
|
58
|
+
date.setDate(date.getDate() + 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var planEls = [];
|
|
64
|
+
|
|
65
|
+
function createPlanBar(plan, idx) {
|
|
66
|
+
var el = document.createElement('div');
|
|
67
|
+
el.className = 'plan-bar';
|
|
68
|
+
el.dataset.path = plan.path;
|
|
69
|
+
el.dataset.id = plan.id;
|
|
70
|
+
var startDateValue = plan.start_date || plan.created_at;
|
|
71
|
+
var left = dayDiff(startDateValue, startDate) * dayWidth;
|
|
72
|
+
var width = (dayDiff(plan.target_date, startDateValue) + 1) * dayWidth;
|
|
73
|
+
el.style.left = left + 'px';
|
|
74
|
+
el.style.top = (idx * rowHeight + 40) + 'px';
|
|
75
|
+
el.style.width = width + 'px';
|
|
76
|
+
|
|
77
|
+
var prog = document.createElement('div');
|
|
78
|
+
prog.className = 'plan-progress';
|
|
79
|
+
prog.style.width = (plan.progress * 100) + '%';
|
|
80
|
+
el.appendChild(prog);
|
|
81
|
+
|
|
82
|
+
var label = document.createElement('span');
|
|
83
|
+
label.className = 'plan-label';
|
|
84
|
+
label.textContent = plan.name + ' ' + Math.round(plan.progress * 100) + '%';
|
|
85
|
+
el.appendChild(label);
|
|
86
|
+
|
|
87
|
+
if (plan.deletable) {
|
|
88
|
+
var del = document.createElement('button');
|
|
89
|
+
del.type = 'button';
|
|
90
|
+
del.textContent = 'Γ';
|
|
91
|
+
del.className = 'delete-plan-btn';
|
|
92
|
+
el.appendChild(del);
|
|
93
|
+
del.addEventListener('click', function (e) {
|
|
94
|
+
e.stopPropagation();
|
|
95
|
+
if (!confirm(container.dataset.deleteConfirm)) return;
|
|
96
|
+
var deleteUrl;
|
|
97
|
+
if (String(plan.id).indexOf('calendar_event_') === 0) {
|
|
98
|
+
deleteUrl = '/calendar_events/' + String(plan.id).replace('calendar_event_', '');
|
|
99
|
+
} else {
|
|
100
|
+
deleteUrl = '/plans/' + plan.id;
|
|
101
|
+
}
|
|
102
|
+
fetch(deleteUrl, {
|
|
103
|
+
method: 'DELETE',
|
|
104
|
+
headers: {
|
|
105
|
+
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content,
|
|
106
|
+
Accept: 'application/json'
|
|
107
|
+
}
|
|
108
|
+
}).then(function (r) {
|
|
109
|
+
if (r.ok) {
|
|
110
|
+
var idx = planEls.findIndex(function (item) { return item.plan.id === plan.id; });
|
|
111
|
+
if (idx > -1) {
|
|
112
|
+
planEls[idx].el.remove();
|
|
113
|
+
planEls.splice(idx, 1);
|
|
114
|
+
}
|
|
115
|
+
plans = plans.filter(function (p) { return p.id !== plan.id; });
|
|
116
|
+
updatePlanPositions();
|
|
117
|
+
} else {
|
|
118
|
+
window.location.reload();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
el.addEventListener('click', function () {
|
|
125
|
+
if (plan.path) {
|
|
126
|
+
window.location.href = plan.path;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return el;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function addPlan(plan) {
|
|
134
|
+
plans.push(plan);
|
|
135
|
+
var el = createPlanBar(plan, planEls.length);
|
|
136
|
+
scroll.appendChild(el);
|
|
137
|
+
planEls.push({ el: el, plan: plan });
|
|
138
|
+
updatePlanPositions();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function renderPlans() {
|
|
142
|
+
plans.forEach(function (plan, idx) {
|
|
143
|
+
var el = createPlanBar(plan, idx);
|
|
144
|
+
scroll.appendChild(el);
|
|
145
|
+
planEls.push({ el: el, plan: plan });
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function updatePlanPositions() {
|
|
150
|
+
var visibleWidth = dayDiff(endDate, startDate) * dayWidth;
|
|
151
|
+
planEls.forEach(function (item, idx) {
|
|
152
|
+
var plan = item.plan;
|
|
153
|
+
var startDateValue = plan.start_date || plan.created_at;
|
|
154
|
+
var left = dayDiff(startDateValue, startDate) * dayWidth;
|
|
155
|
+
var width = (dayDiff(plan.target_date, startDateValue) + 1) * dayWidth;
|
|
156
|
+
var right = left + width;
|
|
157
|
+
|
|
158
|
+
if (right < 0 || left > visibleWidth) {
|
|
159
|
+
item.el.style.display = 'none';
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
item.el.style.display = '';
|
|
164
|
+
item.el.style.left = left + 'px';
|
|
165
|
+
item.el.style.top = (idx * rowHeight + 40) + 'px';
|
|
166
|
+
item.el.style.width = width + 'px';
|
|
167
|
+
|
|
168
|
+
var label = item.el.querySelector('.plan-label');
|
|
169
|
+
var viewLeft = container.scrollLeft;
|
|
170
|
+
var labelLeft = Math.max(viewLeft, left) - left + 2;
|
|
171
|
+
label.style.left = labelLeft + 'px';
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function extendLeft(n) {
|
|
176
|
+
startDate.setDate(startDate.getDate() - n);
|
|
177
|
+
renderDays(startDate, new Date(startDate.getTime() + (n - 1) * 86400000), true);
|
|
178
|
+
updatePlanPositions();
|
|
179
|
+
container.scrollLeft += n * dayWidth;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function extendRight(n) {
|
|
183
|
+
var from = new Date(endDate.getTime() + 86400000);
|
|
184
|
+
endDate.setDate(endDate.getDate() + n);
|
|
185
|
+
renderDays(from, endDate, false);
|
|
186
|
+
updatePlanPositions();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
renderDays(startDate, endDate, false);
|
|
190
|
+
renderPlans();
|
|
191
|
+
updatePlanPositions();
|
|
192
|
+
|
|
193
|
+
function loadPlans(centerDate) {
|
|
194
|
+
var dateStr = centerDate.toISOString().slice(0, 10);
|
|
195
|
+
if (container.dataset.lastLoadedDate === dateStr) return;
|
|
196
|
+
container.dataset.lastLoadedDate = dateStr;
|
|
197
|
+
var listArea = document.getElementById('plans-list-area')
|
|
198
|
+
var basePlansUrl = (listArea && listArea.dataset.plansUrl) || '/plans.json'
|
|
199
|
+
var separator = basePlansUrl.indexOf('?') >= 0 ? '&' : '?'
|
|
200
|
+
fetch(basePlansUrl + separator + 'date=' + dateStr)
|
|
201
|
+
.then(function (r) { return r.json(); })
|
|
202
|
+
.then(function (newPlans) {
|
|
203
|
+
plans = newPlans.map(function (p) {
|
|
204
|
+
if (p.start_date) {
|
|
205
|
+
p.start_date = new Date(p.start_date);
|
|
206
|
+
}
|
|
207
|
+
p.created_at = new Date(p.created_at);
|
|
208
|
+
p.target_date = new Date(p.target_date);
|
|
209
|
+
return p;
|
|
210
|
+
});
|
|
211
|
+
planEls.forEach(function (item) { item.el.remove(); });
|
|
212
|
+
planEls = [];
|
|
213
|
+
renderPlans();
|
|
214
|
+
updatePlanPositions();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function ensureDateVisible(date) {
|
|
219
|
+
if (date < startDate) {
|
|
220
|
+
extendLeft(dayDiff(startDate, date));
|
|
221
|
+
} else if (date > endDate) {
|
|
222
|
+
extendRight(dayDiff(date, endDate));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function scrollToDate(date) {
|
|
227
|
+
ensureDateVisible(date);
|
|
228
|
+
var offset = dayDiff(date, startDate) * dayWidth - container.clientWidth / 2 + dayWidth / 2;
|
|
229
|
+
container.scrollLeft = offset;
|
|
230
|
+
updatePlanPositions();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
var todayBtn = document.getElementById('timeline-today-btn');
|
|
234
|
+
if (todayBtn) {
|
|
235
|
+
todayBtn.addEventListener('click', function () { scrollToDate(new Date()); });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
scrollToDate(new Date());
|
|
239
|
+
|
|
240
|
+
var scrollTimer;
|
|
241
|
+
container.addEventListener('scroll', function () {
|
|
242
|
+
if (container.scrollLeft < 50) {
|
|
243
|
+
extendLeft(30);
|
|
244
|
+
}
|
|
245
|
+
if (container.scrollLeft + container.clientWidth > scroll.scrollWidth - 50) {
|
|
246
|
+
extendRight(30);
|
|
247
|
+
}
|
|
248
|
+
updatePlanPositions();
|
|
249
|
+
clearTimeout(scrollTimer);
|
|
250
|
+
scrollTimer = setTimeout(function () {
|
|
251
|
+
var centerOffset = container.scrollLeft + container.clientWidth / 2;
|
|
252
|
+
var daysFromStart = centerOffset / dayWidth;
|
|
253
|
+
var centerDate = new Date(startDate.getTime() + Math.round(daysFromStart) * 86400000);
|
|
254
|
+
loadPlans(centerDate);
|
|
255
|
+
}, 200);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Listen for plan creation from delegated handler
|
|
259
|
+
const onPlanCreated = function (e) {
|
|
260
|
+
addPlan(e.detail);
|
|
261
|
+
};
|
|
262
|
+
document.addEventListener('plan:created', onPlanCreated);
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
// Creative selector input handler - reuse link-creative-modal
|
|
266
|
+
const planSelectCreativeInput = document.getElementById('plan-select-creative-input');
|
|
267
|
+
if (planSelectCreativeInput) {
|
|
268
|
+
const handleInput = function (e) {
|
|
269
|
+
const modal = document.getElementById('link-creative-modal');
|
|
270
|
+
if (!modal) return;
|
|
271
|
+
|
|
272
|
+
// Get the controller from the application
|
|
273
|
+
const application = window.Stimulus;
|
|
274
|
+
if (!application) return;
|
|
275
|
+
|
|
276
|
+
const controller = application.getControllerForElementAndIdentifier(modal, 'link-creative');
|
|
277
|
+
if (controller) {
|
|
278
|
+
// Open popup if not already open or just update search
|
|
279
|
+
controller.open(planSelectCreativeInput.getBoundingClientRect(), function (item) {
|
|
280
|
+
// Set the creative_id in the hidden field
|
|
281
|
+
const creativeIdField = document.getElementById('plan-creative-id');
|
|
282
|
+
creativeIdField.value = item.id;
|
|
283
|
+
// Trigger change event manually for listeners
|
|
284
|
+
creativeIdField.dispatchEvent(new Event('change'));
|
|
285
|
+
|
|
286
|
+
// Manually check validity if the function exists
|
|
287
|
+
if (typeof checkFormValidity === 'function') checkFormValidity();
|
|
288
|
+
|
|
289
|
+
// Display the selected creative name
|
|
290
|
+
const label = item.label || item.description || 'Creative #' + item.id;
|
|
291
|
+
// Decode HTML entities for display in input
|
|
292
|
+
const txt = document.createElement("textarea");
|
|
293
|
+
txt.innerHTML = label;
|
|
294
|
+
planSelectCreativeInput.value = txt.value;
|
|
295
|
+
|
|
296
|
+
// Trigger validation
|
|
297
|
+
checkFormValidity();
|
|
298
|
+
}, function () {
|
|
299
|
+
// Optional: handle close
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Sync input value to popup search
|
|
303
|
+
const popupInput = document.getElementById('link-creative-search');
|
|
304
|
+
if (popupInput) {
|
|
305
|
+
popupInput.value = planSelectCreativeInput.value;
|
|
306
|
+
popupInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
307
|
+
// Maintain focus on our input
|
|
308
|
+
planSelectCreativeInput.focus();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
planSelectCreativeInput.addEventListener('input', handleInput);
|
|
314
|
+
planSelectCreativeInput.addEventListener('click', handleInput);
|
|
315
|
+
planSelectCreativeInput.addEventListener('focus', handleInput);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Form validation elements
|
|
319
|
+
const planCreativeIdInput = document.getElementById('plan-creative-id');
|
|
320
|
+
const planTargetDateInput = document.getElementById('plan-target-date');
|
|
321
|
+
const addPlanBtn = document.getElementById('add-plan-btn');
|
|
322
|
+
|
|
323
|
+
function checkFormValidity() {
|
|
324
|
+
if (!addPlanBtn) return;
|
|
325
|
+
|
|
326
|
+
const creativeId = planCreativeIdInput ? planCreativeIdInput.value : '';
|
|
327
|
+
const targetDate = planTargetDateInput ? planTargetDateInput.value : '';
|
|
328
|
+
|
|
329
|
+
if (creativeId && targetDate) {
|
|
330
|
+
addPlanBtn.disabled = false;
|
|
331
|
+
} else {
|
|
332
|
+
addPlanBtn.disabled = true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (planCreativeIdInput && planTargetDateInput && addPlanBtn) {
|
|
337
|
+
// Check on date change
|
|
338
|
+
planTargetDateInput.addEventListener('input', checkFormValidity);
|
|
339
|
+
planTargetDateInput.addEventListener('change', checkFormValidity);
|
|
340
|
+
|
|
341
|
+
// Check on creative selection (observer for hidden field)
|
|
342
|
+
const observer = new MutationObserver(checkFormValidity);
|
|
343
|
+
observer.observe(planCreativeIdInput, { attributes: true, attributeFilter: ['value'] });
|
|
344
|
+
|
|
345
|
+
// Initial check
|
|
346
|
+
checkFormValidity();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
window.initPlansTimeline = initPlansTimeline;
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
// Plan form submission handler using delegation at document level
|
|
354
|
+
// This runs once when module is evaluated
|
|
355
|
+
document.addEventListener('submit', function (e) {
|
|
356
|
+
if (e.target && e.target.id === 'new-plan-form') {
|
|
357
|
+
e.preventDefault();
|
|
358
|
+
const planForm = e.target;
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const fd = new FormData(planForm);
|
|
362
|
+
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
363
|
+
|
|
364
|
+
fetch(planForm.action, {
|
|
365
|
+
method: planForm.method,
|
|
366
|
+
body: fd,
|
|
367
|
+
headers: {
|
|
368
|
+
'Accept': 'application/json',
|
|
369
|
+
'X-CSRF-Token': csrfToken
|
|
370
|
+
}
|
|
371
|
+
}).then(function (r) {
|
|
372
|
+
if (r.ok) return r.json();
|
|
373
|
+
return r.json().then(function (j) { throw j; });
|
|
374
|
+
}).then(function (plan) {
|
|
375
|
+
// Add to local plans if available via closure or re-fetch
|
|
376
|
+
if (plan.start_date) {
|
|
377
|
+
plan.start_date = new Date(plan.start_date);
|
|
378
|
+
}
|
|
379
|
+
plan.created_at = new Date(plan.created_at);
|
|
380
|
+
plan.target_date = new Date(plan.target_date);
|
|
381
|
+
|
|
382
|
+
// Find the timeline instance to update
|
|
383
|
+
const timeline = document.getElementById('plans-timeline');
|
|
384
|
+
if (timeline && window.initPlansTimeline) {
|
|
385
|
+
const event = new CustomEvent('plan:created', { detail: plan });
|
|
386
|
+
document.dispatchEvent(event);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
planForm.reset();
|
|
390
|
+
// Clear creative selection
|
|
391
|
+
const creativeIdInput = document.getElementById('plan-creative-id');
|
|
392
|
+
if (creativeIdInput) creativeIdInput.value = '';
|
|
393
|
+
const creativeInput = document.getElementById('plan-select-creative-input');
|
|
394
|
+
if (creativeInput) creativeInput.value = '';
|
|
395
|
+
|
|
396
|
+
// Disable button again
|
|
397
|
+
const addPlanBtn = document.getElementById('add-plan-btn');
|
|
398
|
+
if (addPlanBtn) addPlanBtn.disabled = true;
|
|
399
|
+
}).catch(function (err) {
|
|
400
|
+
if (err && err.errors) {
|
|
401
|
+
alert(err.errors.join(', '));
|
|
402
|
+
} else {
|
|
403
|
+
console.error(err);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
} catch (e) {
|
|
407
|
+
console.error(e);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
class Plan < Label
|
|
5
|
+
validates :target_date, presence: true
|
|
6
|
+
validate :start_date_not_after_target_date
|
|
7
|
+
|
|
8
|
+
def progress(_user = nil)
|
|
9
|
+
tagged_ids = Tag.where(label_id: id).pluck(:creative_id)
|
|
10
|
+
return 0 if tagged_ids.empty?
|
|
11
|
+
|
|
12
|
+
root_ids = Creative.where(id: tagged_ids).map { |c| c.root.id }.uniq
|
|
13
|
+
roots = Creative.where(id: root_ids)
|
|
14
|
+
tagged_set = tagged_ids.to_set
|
|
15
|
+
values = roots.map { |c| c.progress_for_plan(tagged_set) }.compact
|
|
16
|
+
return 0 if values.empty?
|
|
17
|
+
|
|
18
|
+
values.sum.to_f / values.size
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Delegate start_date to the associated creative's created_at
|
|
22
|
+
def start_date
|
|
23
|
+
creative&.created_at&.to_date
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def start_date=(value)
|
|
27
|
+
return unless creative
|
|
28
|
+
|
|
29
|
+
date = value.is_a?(Date) ? value : Date.parse(value.to_s)
|
|
30
|
+
creative.update_column(:created_at, date.to_datetime)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def start_date_not_after_target_date
|
|
36
|
+
return if start_date.blank? || target_date.blank?
|
|
37
|
+
|
|
38
|
+
return unless start_date > target_date
|
|
39
|
+
|
|
40
|
+
errors.add(:start_date, "must be on or before target date")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
class PlanTagger
|
|
4
|
+
Result = Struct.new(:success?, :message, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
def initialize(plan_id:, creative_ids: [], user: nil)
|
|
7
|
+
@plan = Plan.find_by(id: plan_id)
|
|
8
|
+
@creative_ids = Array(creative_ids).map(&:presence).compact
|
|
9
|
+
@user = user
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def apply
|
|
13
|
+
return failure("Please select a plan and at least one creative.") unless valid?
|
|
14
|
+
|
|
15
|
+
creatives.find_each do |creative|
|
|
16
|
+
creative.tags.find_or_create_by(label: plan, creative_id: creative.id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
success("Plan tags applied to selected creatives.")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def remove
|
|
23
|
+
return failure("Please select a plan and at least one creative.") unless valid?
|
|
24
|
+
|
|
25
|
+
creatives.find_each do |creative|
|
|
26
|
+
tag = creative.tags.find_by(label: plan, creative_id: creative.id)
|
|
27
|
+
tag&.destroy
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
success("Plan tag removed from selected creatives.")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :plan, :creative_ids, :user
|
|
36
|
+
|
|
37
|
+
def creatives
|
|
38
|
+
all_creatives = Creative.where(id: creative_ids)
|
|
39
|
+
return all_creatives unless user
|
|
40
|
+
|
|
41
|
+
# Scope to creatives the user has write permission for via creative_shares
|
|
42
|
+
permitted_ids = all_creatives.joins(:creative_shares)
|
|
43
|
+
.where(creative_shares: { user_id: user.id })
|
|
44
|
+
.where("creative_shares.permission IN (?)", %w[write admin])
|
|
45
|
+
.pluck(:id)
|
|
46
|
+
# Also include creatives owned by the user
|
|
47
|
+
owned_ids = all_creatives.where(user_id: user.id).pluck(:id)
|
|
48
|
+
Creative.where(id: (permitted_ids + owned_ids).uniq)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def valid?
|
|
52
|
+
plan.present? && creative_ids.any?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def success(message)
|
|
56
|
+
Result.new(success?: true, message: message)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def failure(message)
|
|
60
|
+
Result.new(success?: false, message: message)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<%# This partial lives in collavre_plan engine but uses the collavre/creatives/ view path
|
|
2
|
+
so that core's index.html.erb can render it via the :creative_modals extension slot.
|
|
3
|
+
Rails resolves this through the engine's view path order. %>
|
|
4
|
+
<div data-controller="creatives--set-plan-modal"
|
|
5
|
+
data-creatives--set-plan-modal-select-one-value="<%= t('collavre.creatives.index.select_one_creative') %>"
|
|
6
|
+
data-creatives--set-plan-modal-select-plan-value="<%= t('collavre.creatives.index.select_plan_to_remove') %>">
|
|
7
|
+
<div id="set-plan-modal" data-creatives--set-plan-modal-target="modal" data-action="click->creatives--set-plan-modal#backdrop" style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:10000;align-items:center;justify-content:center;">
|
|
8
|
+
<div class="popup-box" style="min-width:320px;max-width:90vw;">
|
|
9
|
+
<button id="close-set-plan-modal" class="popup-close-btn" data-action="creatives--set-plan-modal#close">×</button>
|
|
10
|
+
<h2><%= t('collavre.creatives.index.set_plan_title', default: 'Set Plan for Selected Creatives') %></h2>
|
|
11
|
+
<form id="set-plan-form" method="post" action="<%= collavre_plan_engine.creative_plan_path %>" data-creatives--set-plan-modal-target="form" data-action="submit->creatives--set-plan-modal#submit">
|
|
12
|
+
<%= csrf_meta_tags %>
|
|
13
|
+
<div style="margin-bottom:1em;">
|
|
14
|
+
<label for="plan-id-select"><%= t('collavre.creatives.index.select_plan', default: 'Select Plan') %></label>
|
|
15
|
+
<select id="plan-id-select" name="plan_id" required style="width:100%;" data-creatives--set-plan-modal-target="planSelect">
|
|
16
|
+
<% Collavre::Plan.all.each do |plan| %>
|
|
17
|
+
<option value="<%= plan.id %>"><%= plan.creative&.effective_description(nil, false).presence || plan.target_date %></option>
|
|
18
|
+
<% end %>
|
|
19
|
+
</select>
|
|
20
|
+
</div>
|
|
21
|
+
<!-- Hidden field for creative IDs, filled by JS -->
|
|
22
|
+
<input type="hidden" name="creative_ids" id="selected-creative-ids-input" data-creatives--set-plan-modal-target="idsInput" />
|
|
23
|
+
<div style="display:flex; gap:1em; align-items:center;">
|
|
24
|
+
<button type="button" id="remove-plan-btn" data-remove-path="<%= collavre_plan_engine.creative_plan_path %>" class="btn btn-danger" style="margin-right:0.5em;" data-action="click->creatives--set-plan-modal#remove">
|
|
25
|
+
<%= t('collavre.creatives.index.remove_plan', default: 'Remove Plan') %>
|
|
26
|
+
</button>
|
|
27
|
+
<button type="submit" class="btn btn-primary"><%= t('collavre.creatives.index.add_plan', default: 'Add Plan') %></button>
|
|
28
|
+
</div>
|
|
29
|
+
</form>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%# Plan label extra UI: date editing form or read-only date display.
|
|
2
|
+
Provided by collavre_plan engine via the label type partial convention.
|
|
3
|
+
Core renders this via render_label_extra(label) when label.type == "Plan". %>
|
|
4
|
+
<% if parent_creative&.has_permission?(Current.user, :write) %>
|
|
5
|
+
<%= form_with(model: label, url: collavre_plan_engine.plan_path(label), method: :patch, local: true, class: "plan-tag-date-form") do |form| %>
|
|
6
|
+
<%= hidden_field_tag :creative_id, parent_creative.id %>
|
|
7
|
+
π
|
|
8
|
+
<%= form.date_field :start_date, value: label.start_date, class: "plan-tag-date-input" %>
|
|
9
|
+
<%= form.date_field :target_date, value: label.target_date, class: "plan-tag-date-input" %>
|
|
10
|
+
<%= form.submit t("collavre.plans.update", default: "Update"), class: "plan-tag-date-submit" %>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% else %>
|
|
13
|
+
π<%= label.target_date %>
|
|
14
|
+
<% end %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<button id="set-plan-btn" class="set-plan-btn" style="display:none;" data-creatives--select-mode-target="setPlan" onclick="document.dispatchEvent(new CustomEvent('plan:open-modal'))"><%= t('app.set_plan') %></button>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<button class="plans-menu-btn mobile-only" type="button"><%= t('app.plans') %></button>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
en:
|
|
3
|
+
collavre:
|
|
4
|
+
plans:
|
|
5
|
+
add_plan: Add Plan
|
|
6
|
+
start_date: Start Date
|
|
7
|
+
target_date: Target Date
|
|
8
|
+
plan_name: Plan Name
|
|
9
|
+
created: Plan was successfully created.
|
|
10
|
+
deleted: Plan deleted.
|
|
11
|
+
updated: Plan updated.
|
|
12
|
+
update: Update
|
|
13
|
+
update_forbidden: You do not have permission to update this plan.
|
|
14
|
+
delete_confirm: Are you sure you want to delete this plan?
|
|
15
|
+
today: Today
|
|
16
|
+
select_creative: Select Creative
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
ko:
|
|
3
|
+
collavre:
|
|
4
|
+
plans:
|
|
5
|
+
add_plan: κ³ν μΆκ°
|
|
6
|
+
start_date: μμ λ μ§
|
|
7
|
+
target_date: λͺ©ν λ μ§
|
|
8
|
+
plan_name: κ³ν μ΄λ¦
|
|
9
|
+
created: κ³νμ΄ μ±κ³΅μ μΌλ‘ μμ±λμμ΅λλ€.
|
|
10
|
+
deleted: κ³νμ΄ μμ λμμ΅λλ€.
|
|
11
|
+
updated: κ³νμ΄ μ
λ°μ΄νΈλμμ΅λλ€.
|
|
12
|
+
update: μ
λ°μ΄νΈ
|
|
13
|
+
update_forbidden: μ΄ κ³νμ μ
λ°μ΄νΈν κΆνμ΄ μμ΅λλ€.
|
|
14
|
+
delete_confirm: μ΄ κ³νμ μμ νμκ² μ΅λκΉ?
|
|
15
|
+
today: μ€λ
|
|
16
|
+
select_creative: ν¬λ¦¬μμ΄ν°λΈ μ ν
|
|
17
|
+
activerecord:
|
|
18
|
+
errors:
|
|
19
|
+
models:
|
|
20
|
+
plan:
|
|
21
|
+
attributes:
|
|
22
|
+
target_date:
|
|
23
|
+
blank: λͺ©ν λ μ§λ₯Ό μ
λ ₯νμΈμ.
|
data/config/routes.rb
ADDED