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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f1f4d20acde286797f3e083741480a5c654a0c93f9a674c09cedfc5bc7865f55
4
+ data.tar.gz: 81034f4bd8a3e0c6b36bd8d59786fbae8f5f0e141fcf34c29d88fce8feadf199
5
+ SHA512:
6
+ metadata.gz: 6a366c4ef91d21b2caba0215f42ac7a1489b9b5ed251039da6baf12858b7f0de7e0cca533a3783d7d21f3839aa642811dc86e62a75522333f882a3bd1d30652f
7
+ data.tar.gz: e75dd6df51d244be43629f6ac461493810c71d61e6b4db42d8261d1dd574c71deb1b50b328cfc8ce5015c4ac2a10c0d7e55dde085f880d6a29cfb37e9ab1c9af
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
@@ -0,0 +1,14 @@
1
+ <div class="plans-timeline-wrapper">
2
+ <button id="timeline-today-btn" class="btn btn-xs" type="button"><%= t('collavre.plans.today', default: '오늘') %></button>
3
+ <div id="plans-timeline" class="horizontal-timeline" data-plans='<%= raw plan_data.to_json %>' data-start-date="<%= @start_date %>" data-end-date="<%= @end_date %>" data-delete-confirm="<%= t('collavre.plans.delete_confirm', default: 'Are you sure?') %>"></div>
4
+ </div>
5
+ <hr>
6
+ <div class="plan-form-container">
7
+ <%= form_with(model: Collavre::Plan.new, url: CollavrePlan::Engine.routes.url_helpers.plans_path, local: true, id: 'new-plan-form') do |form| %>
8
+ <%= form.hidden_field :creative_id, id: 'plan-creative-id' %>
9
+ <input type="text" id="plan-select-creative-input" placeholder="<%= t('collavre.plans.select_creative', default: 'Select Creative') %>" autocomplete="off">
10
+ <%= form.date_field :start_date, placeholder: t('collavre.plans.start_date'), id: 'plan-start-date' %>
11
+ <%= form.date_field :target_date, placeholder: t('collavre.plans.target_date'), id: 'plan-target-date' %>
12
+ <%= form.submit t('collavre.plans.add_plan'), id: 'add-plan-btn', class: 'btn btn-sm btn-primary', disabled: true %>
13
+ <% end %>
14
+ </div>
@@ -0,0 +1,53 @@
1
+ module Collavre
2
+ class PlansTimelineComponent < ViewComponent::Base
3
+ # Accepts pre-filtered plans and calendar_events from the controller
4
+ def initialize(plans:, calendar_events: Collavre::CalendarEvent.none)
5
+ @start_date = Date.current - 30
6
+ @end_date = Date.current + 30
7
+ @plans = plans
8
+ @calendar_events = calendar_events
9
+ end
10
+
11
+ attr_reader :plans, :calendar_events, :start_date, :end_date
12
+
13
+ # Called after component enters render context - safe to use helpers here
14
+ def plan_data
15
+ @plan_data ||= @plans.map { |plan| plan_item(plan) } + @calendar_events.map { |event| calendar_item(event) }
16
+ end
17
+
18
+ private
19
+
20
+ def plan_item(plan)
21
+ {
22
+ id: plan.id,
23
+ name: (plan.creative&.effective_description(nil, false) || plan.name.presence || I18n.l(plan.target_date)),
24
+ created_at: plan.created_at.to_date,
25
+ start_date: plan.start_date,
26
+ target_date: plan.target_date,
27
+ progress: plan.progress,
28
+ path: plan_creatives_path(plan),
29
+ deletable: plan.owner_id == Current.user&.id
30
+ }
31
+ end
32
+
33
+ def calendar_item(event)
34
+ {
35
+ id: "calendar_event_#{event.id}",
36
+ name: event.summary.presence || I18n.l(event.start_time.to_date),
37
+ created_at: event.start_time.to_date,
38
+ target_date: event.end_time.to_date,
39
+ progress: event.creative&.progress || 0,
40
+ path: event.creative ? helpers.collavre.creative_path(event.creative) : event.html_link,
41
+ deletable: event.user_id == Current.user&.id
42
+ }
43
+ end
44
+
45
+ def plan_creatives_path(plan)
46
+ if helpers.params[:id].present?
47
+ helpers.collavre.creative_path(helpers.params[:id], tags: [ plan.id ])
48
+ else
49
+ helpers.collavre.creatives_path(tags: [ plan.id ])
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,4 @@
1
+ module CollavrePlan
2
+ class ApplicationController < Collavre::ApplicationController
3
+ end
4
+ end
@@ -0,0 +1,69 @@
1
+ module CollavrePlan
2
+ class CreativePlansController < ApplicationController
3
+ before_action :require_authentication
4
+
5
+ def create
6
+ result = tagger.apply
7
+ respond_to do |format|
8
+ format.html do
9
+ flash[result.success? ? :notice : :alert] = translate_message(
10
+ result,
11
+ success_key: "collavre.creatives.index.plan_tags_applied",
12
+ success_default: "Plan tags applied to selected creatives.",
13
+ failure_key: "collavre.creatives.index.plan_tag_failed",
14
+ failure_default: "Please select a plan and at least one creative."
15
+ )
16
+ redirect_back fallback_location: Collavre::Engine.routes.url_helpers.creatives_path(select_mode: 1)
17
+ end
18
+ format.json do
19
+ if result.success?
20
+ render json: { message: translate_message(result, success_key: "collavre.creatives.index.plan_tags_applied", success_default: "Plan tags applied.", failure_key: "", failure_default: "") }, status: :ok
21
+ else
22
+ render json: { error: translate_message(result, success_key: "", success_default: "", failure_key: "collavre.creatives.index.plan_tag_failed", failure_default: "Failed to apply plan.") }, status: :unprocessable_entity
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ def destroy
29
+ result = tagger.remove
30
+ respond_to do |format|
31
+ format.html do
32
+ flash[result.success? ? :notice : :alert] = translate_message(
33
+ result,
34
+ success_key: "collavre.creatives.index.plan_tags_removed",
35
+ success_default: "Plan tag removed from selected creatives.",
36
+ failure_key: "collavre.creatives.index.plan_tag_remove_failed",
37
+ failure_default: "Please select a plan and at least one creative."
38
+ )
39
+ redirect_back fallback_location: Collavre::Engine.routes.url_helpers.creatives_path(select_mode: 1)
40
+ end
41
+ format.json do
42
+ if result.success?
43
+ render json: { message: translate_message(result, success_key: "collavre.creatives.index.plan_tags_removed", success_default: "Plan tag removed.", failure_key: "", failure_default: "") }, status: :ok
44
+ else
45
+ render json: { error: translate_message(result, success_key: "", success_default: "", failure_key: "collavre.creatives.index.plan_tag_remove_failed", failure_default: "Failed to remove plan.") }, status: :unprocessable_entity
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def tagger
54
+ Collavre::Creatives::PlanTagger.new(plan_id: params[:plan_id], creative_ids: parsed_creative_ids, user: Current.user)
55
+ end
56
+
57
+ def parsed_creative_ids
58
+ params[:creative_ids].to_s.split(",").map(&:strip).reject(&:blank?)
59
+ end
60
+
61
+ def translate_message(result, success_key:, success_default:, failure_key:, failure_default:)
62
+ if result.success?
63
+ I18n.t(success_key, default: success_default)
64
+ else
65
+ I18n.t(failure_key, default: failure_default)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,170 @@
1
+ module CollavrePlan
2
+ class PlansController < ApplicationController
3
+ def index
4
+ center = if params[:date].present?
5
+ Date.parse(params[:date]) rescue Date.current
6
+ else
7
+ Date.current
8
+ end
9
+ start_date = center - 30
10
+ end_date = center + 30
11
+ @plans = Collavre::Plan.joins(:creative)
12
+ .where("target_date >= ? AND DATE(creatives.created_at) <= ?", start_date, end_date)
13
+ .order(Arel.sql("DATE(creatives.created_at) ASC"))
14
+ .select { |plan| plan.readable_by?(Current.user) }
15
+ calendar_scope = Collavre::CalendarEvent.includes(:creative)
16
+ .where("DATE(start_time) <= ? AND DATE(end_time) >= ?", end_date, start_date)
17
+ .order(:start_time)
18
+ events_in_scope = calendar_scope.to_a
19
+ own_events = events_in_scope.select { |event| event.user_id == Current.user.id }
20
+ shared_events = events_in_scope.reject { |event| event.user_id == Current.user.id }
21
+ .select { |event| event.creative&.has_permission?(Current.user, :write) }
22
+ @calendar_events = (own_events + shared_events).uniq.sort_by(&:start_time)
23
+ respond_to do |format|
24
+ format.html do
25
+ render html: render_to_string(Collavre::PlansTimelineComponent.new(plans: @plans, calendar_events: @calendar_events), layout: false)
26
+ end
27
+ format.json do
28
+ plan_jsons = @plans.map { |p| plan_json(p) }
29
+ event_jsons = @calendar_events.map { |e| calendar_json(e) }
30
+ render json: plan_jsons + event_jsons
31
+ end
32
+ end
33
+ end
34
+
35
+ def create
36
+ @plan = Collavre::Plan.new(plan_params)
37
+ @plan.owner = Current.user
38
+ if @plan.save
39
+ respond_to do |format|
40
+ format.html do
41
+ redirect_back fallback_location: main_app.root_path, notice: t("collavre.plans.created")
42
+ end
43
+ format.json do
44
+ render json: plan_json(@plan), status: :created
45
+ end
46
+ end
47
+ else
48
+ respond_to do |format|
49
+ format.html do
50
+ flash[:alert] = @plan.errors.full_messages.join(", ")
51
+ redirect_back fallback_location: main_app.root_path
52
+ end
53
+ format.json do
54
+ render json: { errors: @plan.errors.full_messages }, status: :unprocessable_entity
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ def destroy
61
+ @plan = Collavre::Plan.find(params[:id])
62
+ return render_forbidden unless plan_editable_by_current_user?
63
+
64
+ @plan.destroy
65
+ respond_to do |format|
66
+ format.html do
67
+ redirect_back fallback_location: main_app.root_path,
68
+ notice: t("collavre.plans.deleted", default: "Plan deleted.")
69
+ end
70
+ format.json { head :no_content }
71
+ end
72
+ end
73
+
74
+ def update
75
+ @plan = Collavre::Plan.find(params[:id])
76
+ return render_forbidden unless plan_editable_by_current_user?
77
+
78
+ if @plan.update(plan_update_params)
79
+ respond_to do |format|
80
+ format.html do
81
+ redirect_back fallback_location: main_app.root_path,
82
+ notice: t("collavre.plans.updated", default: "Plan updated.")
83
+ end
84
+ format.json do
85
+ render json: plan_json(@plan, creative_id: params[:creative_id] || @plan.creative_id), status: :ok
86
+ end
87
+ end
88
+ else
89
+ respond_to do |format|
90
+ format.html do
91
+ flash[:alert] = @plan.errors.full_messages.join(", ")
92
+ redirect_back fallback_location: main_app.root_path
93
+ end
94
+ format.json do
95
+ render json: { errors: @plan.errors.full_messages }, status: :unprocessable_entity
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def plan_params
104
+ params.require(:plan).permit(:target_date, :start_date, :creative_id)
105
+ end
106
+
107
+ def plan_update_params
108
+ params.require(:plan).permit(:target_date, :start_date)
109
+ end
110
+
111
+ def plan_editable_by_current_user?
112
+ return true if @plan.owner_id == Current.user&.id
113
+ return true if @plan.creative&.has_permission?(Current.user, :write)
114
+
115
+ tagged_creative = Collavre::Creative.find_by(id: params[:creative_id])
116
+ return false unless tagged_creative
117
+ return false unless @plan.tags.exists?(creative_id: tagged_creative.id)
118
+
119
+ tagged_creative.has_permission?(Current.user, :write)
120
+ end
121
+
122
+ def render_forbidden
123
+ respond_to do |format|
124
+ format.html do
125
+ redirect_back fallback_location: main_app.root_path,
126
+ alert: t("collavre.plans.update_forbidden", default: "You do not have permission to update this plan.")
127
+ end
128
+ format.json do
129
+ render json: { error: "forbidden" }, status: :forbidden
130
+ end
131
+ end
132
+ end
133
+
134
+ def plan_json(plan, creative_id: nil)
135
+ {
136
+ id: plan.id,
137
+ name: (plan.creative&.effective_description(nil, false) || plan.name.presence || I18n.l(plan.target_date)),
138
+ created_at: plan.created_at.to_date,
139
+ start_date: plan.start_date,
140
+ target_date: plan.target_date,
141
+ progress: plan.progress,
142
+ path: plan_creatives_path(plan, creative_id: creative_id),
143
+ deletable: plan.owner_id == Current.user&.id
144
+ }
145
+ end
146
+
147
+ def calendar_json(event)
148
+ {
149
+ id: "calendar_event_#{event.id}",
150
+ name: event.summary.presence || I18n.l(event.start_time.to_date),
151
+ created_at: event.start_time.to_date,
152
+ target_date: event.end_time.to_date,
153
+ progress: event.creative&.progress || 0,
154
+ path: event.creative ? Collavre::Engine.routes.url_helpers.creative_path(event.creative) : event.html_link,
155
+ deletable: event.user_id == Current.user&.id
156
+ }
157
+ end
158
+
159
+ def plan_creatives_path(plan, creative_id: nil)
160
+ collavre_routes = Collavre::Engine.routes.url_helpers
161
+ if creative_id.present?
162
+ collavre_routes.creative_path(creative_id, tags: [ plan.id ])
163
+ elsif params[:id].present?
164
+ collavre_routes.creative_path(params[:id], tags: [ plan.id ])
165
+ else
166
+ collavre_routes.creatives_path(tags: [ plan.id ])
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,8 @@
1
+ // CollavrePlan Engine - Main Entry Point
2
+
3
+ // Import side-effect modules
4
+ import "./modules/plans_timeline"
5
+ import "./modules/plans_menu"
6
+
7
+ // Export controller registration
8
+ export { registerControllers } from "./controllers"
@@ -0,0 +1,124 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // Self-contained controller: attaches to the modal wrapper element
4
+ // and listens for 'plan:open-modal' on document for cross-element communication.
5
+ export default class extends Controller {
6
+ static targets = ['modal', 'form', 'idsInput', 'planSelect']
7
+ static values = {
8
+ selectOne: String,
9
+ selectPlan: String,
10
+ }
11
+
12
+ connect() {
13
+ this._openHandler = () => this._open()
14
+ document.addEventListener('plan:open-modal', this._openHandler)
15
+ }
16
+
17
+ disconnect() {
18
+ document.removeEventListener('plan:open-modal', this._openHandler)
19
+ }
20
+
21
+ // Called via document event from the set-plan button
22
+ _open() {
23
+ if (!this.hasModalTarget) return
24
+ this.modalTarget.style.display = 'flex'
25
+ document.body.classList.add('no-scroll')
26
+ }
27
+
28
+ // Can still be called via data-action for elements inside the controller scope
29
+ open(event) {
30
+ event.preventDefault()
31
+ this._open()
32
+ }
33
+
34
+ close(event) {
35
+ if (event) event.preventDefault()
36
+ if (!this.hasModalTarget) return
37
+ this.modalTarget.style.display = 'none'
38
+ document.body.classList.remove('no-scroll')
39
+ }
40
+
41
+ backdrop(event) {
42
+ if (event.target === this.modalTarget) {
43
+ this.close(event)
44
+ }
45
+ }
46
+
47
+ async submit(event) {
48
+ event.preventDefault()
49
+ if (!this.hasFormTarget || !this.hasIdsInputTarget) return
50
+ const ids = this.selectedIds()
51
+
52
+ if (ids.length === 0) {
53
+ this.alert(this.selectOneValue)
54
+ return
55
+ }
56
+
57
+ const planId = this.planSelectTarget.value
58
+ if (!planId) {
59
+ this.alert(this.selectPlanValue)
60
+ return
61
+ }
62
+
63
+ await this.performRequest(this.formTarget.action, 'POST', {
64
+ plan_id: planId,
65
+ creative_ids: ids.join(',')
66
+ })
67
+ }
68
+
69
+ async remove(event) {
70
+ event.preventDefault()
71
+ const ids = this.selectedIds()
72
+ if (ids.length === 0) {
73
+ this.alert(this.selectOneValue)
74
+ return
75
+ }
76
+
77
+ const planId = this.planSelectTarget.value
78
+ if (!planId) {
79
+ this.alert(this.selectPlanValue)
80
+ return
81
+ }
82
+
83
+ await this.performRequest(event.currentTarget.dataset.removePath, 'DELETE', {
84
+ plan_id: planId,
85
+ creative_ids: ids.join(',')
86
+ })
87
+ }
88
+
89
+ async performRequest(url, method, body) {
90
+ try {
91
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
92
+ const response = await fetch(url, {
93
+ method: method,
94
+ headers: {
95
+ 'Content-Type': 'application/json',
96
+ 'Accept': 'application/json',
97
+ 'X-CSRF-Token': csrfToken
98
+ },
99
+ body: JSON.stringify(body)
100
+ })
101
+
102
+ if (response.ok) {
103
+ const data = await response.json()
104
+ this.alert(data.message)
105
+ this.close()
106
+ } else {
107
+ const data = await response.json()
108
+ this.alert(data.error || 'Operation failed')
109
+ }
110
+ } catch (error) {
111
+ console.error(error)
112
+ this.alert('An unexpected error occurred.')
113
+ }
114
+ }
115
+
116
+ selectedIds() {
117
+ return Array.from(document.querySelectorAll('.select-creative-checkbox:checked')).map((checkbox) => checkbox.value)
118
+ }
119
+
120
+ alert(message) {
121
+ if (!message) return
122
+ window.alert(message)
123
+ }
124
+ }
@@ -0,0 +1,6 @@
1
+ // CollavrePlan Engine Controllers
2
+ import CreativesSetPlanModalController from "./creatives/set_plan_modal_controller"
3
+
4
+ export function registerControllers(application) {
5
+ application.register("creatives--set-plan-modal", CreativesSetPlanModalController)
6
+ }
@@ -0,0 +1,50 @@
1
+ // Plans menu functionality
2
+ // Handles the plans menu button click and lazy-loads plans data
3
+
4
+ import { notifyPopupOpen, onOtherPopupOpen } from 'collavre/lib/gnb_popup_manager'
5
+
6
+ const POPUP_ID = 'plans-menu'
7
+ let initialized = false
8
+
9
+ function initPlansMenu() {
10
+ if (initialized) return
11
+ initialized = true
12
+
13
+ document.addEventListener('turbo:load', function() {
14
+ const btns = document.querySelectorAll('.plans-menu-btn')
15
+ const area = document.getElementById('plans-list-area')
16
+ let loaded = false
17
+ const timeline = document.getElementById('plans-timeline')
18
+
19
+ function closePlans() {
20
+ if (area) area.style.display = 'none'
21
+ }
22
+
23
+ onOtherPopupOpen(POPUP_ID, closePlans)
24
+
25
+ btns.forEach(function(btn) {
26
+ btn.addEventListener('click', function() {
27
+ if (area.style.display === 'none') {
28
+ notifyPopupOpen(POPUP_ID)
29
+ area.style.display = 'block'
30
+ if (!loaded) {
31
+ const plansUrl = area.dataset.plansUrl || '/plans.json'
32
+ fetch(plansUrl)
33
+ .then(function(r) { return r.json() })
34
+ .then(function(plans) {
35
+ if (timeline) { timeline.dataset.plans = JSON.stringify(plans) }
36
+ if (window.initPlansTimeline && timeline) {
37
+ window.initPlansTimeline(timeline)
38
+ }
39
+ loaded = true
40
+ })
41
+ }
42
+ } else {
43
+ area.style.display = 'none'
44
+ }
45
+ })
46
+ })
47
+ })
48
+ }
49
+
50
+ initPlansMenu()