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.
@@ -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">&times;</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,3 @@
1
+ <%# Plan label suffix: calendar emoji + target date shown in tag lists.
2
+ Provided by collavre_plan engine via the label type partial convention.
3
+ Core renders this via render_label_suffix(label) when label.type == "Plan". %> πŸ—“<%= label.target_date %>
@@ -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,3 @@
1
+ <div id="plans-list-area" style="display:none;" data-plans-url="<%= collavre_plan_engine.plans_path(format: :json) %>">
2
+ <%= render Collavre::PlansTimelineComponent.new(plans: Collavre::Plan.none) %>
3
+ </div>
@@ -0,0 +1,3 @@
1
+ <form id="button_to">
2
+ <button id="plans-menu-btn" class="plans-menu-btn" type="button"><%= t('app.plans') %></button>
3
+ </form>
@@ -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
@@ -0,0 +1,4 @@
1
+ CollavrePlan::Engine.routes.draw do
2
+ resources :plans, only: [ :create, :destroy, :index, :update ]
3
+ resource :creative_plan, only: [ :create, :destroy ], controller: "creative_plans"
4
+ end