diff --git a/app/controllers/oroshi/documentation_controller.rb b/app/controllers/oroshi/documentation_controller.rb new file mode 100644 index 0000000..aeb4aad --- /dev/null +++ b/app/controllers/oroshi/documentation_controller.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class Oroshi::DocumentationController < Oroshi::ApplicationController + layout "documentation" + + skip_before_action :maybe_authenticate_user, raise: false + before_action :authenticate_user_for_docs + before_action :set_locale + before_action :set_navigation + + SECTIONS = { + getting_started: %w[first_login navigation onboarding], + orders: %w[creating_orders order_templates order_lifecycle bundling_orders searching_orders dashboard_tabs], + supply_chain: %w[supply_intake suppliers supply_types supply_check_sheets], + production: %w[production_zones production_requests factory_floor], + shipping: %w[shipping_methods receptacles shipping_dashboard], + financials: %w[revenue_tracking profit_calculation payment_receipts invoices materials_costs], + admin: %w[company_setup buyer_management product_management user_management] + }.freeze + + ALL_SECTIONS = SECTIONS.keys.map(&:to_s).freeze + + # GET /documentation + def index + end + + # GET /documentation/:section + def section + @section = params[:section] + return redirect_to documentation_index_path, alert: t("oroshi.documentation.messages.invalid_section") unless ALL_SECTIONS.include?(@section) + + render "oroshi/documentation/#{@section}/index" + end + + # GET /documentation/:section/:page + def page + @section = params[:section] + @page = params[:page] + + return redirect_to documentation_index_path, alert: t("oroshi.documentation.messages.invalid_section") unless ALL_SECTIONS.include?(@section) + + pages = SECTIONS[@section.to_sym] + return redirect_to documentation_section_path(@section), alert: t("oroshi.documentation.messages.invalid_page") unless pages&.include?(@page) + + render "oroshi/documentation/#{@section}/#{@page}" + end + + private + + def authenticate_user_for_docs + return unless defined?(Devise) + return if respond_to?(:current_user) && current_user.present? + + if respond_to?(:authenticate_user!, true) + authenticate_user! + else + redirect_to root_path, alert: t("common.messages.sign_in_required") + end + end + + def set_locale + if params[:locale].present? && %w[ja en].include?(params[:locale]) + I18n.locale = params[:locale].to_sym + session[:docs_locale] = params[:locale] + elsif session[:docs_locale].present? + I18n.locale = session[:docs_locale].to_sym + end + end + + def set_navigation + @sections = SECTIONS + @current_section = params[:section] + @current_page = params[:page] + end +end diff --git a/app/helpers/oroshi/documentation_helper.rb b/app/helpers/oroshi/documentation_helper.rb new file mode 100644 index 0000000..c42bb18 --- /dev/null +++ b/app/helpers/oroshi/documentation_helper.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module Oroshi + module DocumentationHelper + # Generate a link to a documentation page with bilingual support + def doc_link_to(section, page = nil, options = {}) + label = if page + t("oroshi.documentation.pages.#{section}.#{page}") + else + t("oroshi.documentation.sections.#{section}") + end + + path = if page + documentation_page_path(section: section, page: page, locale: I18n.locale) + else + documentation_section_path(section: section, locale: I18n.locale) + end + + css_class = options[:class] || "doc-link" + link_to label, path, class: css_class + end + + # Generate a "See Also" cross-reference list + def doc_see_also(*references) + content_tag :div, class: "doc-see-also mt-4 p-3 bg-light rounded" do + concat content_tag(:h6, t("oroshi.documentation.chrome.see_also"), class: "text-muted mb-2") + concat content_tag(:ul, class: "list-unstyled mb-0") { + references.each do |ref| + section, page = ref.to_s.split("/") + concat content_tag(:li, class: "mb-1") { + concat icon("arrow-right-short", class: "text-primary me-1") + concat doc_link_to(section, page) + } + end + } + end + end + + # Contextual help icon that links from main app to documentation + def documentation_help_link(section, page = nil) + path = if page + documentation_page_path(section: section, page: page, locale: I18n.locale) + else + documentation_section_path(section: section, locale: I18n.locale) + end + + link_to path, class: "doc-help-link text-muted", target: "_blank", + data: { tippy_content: t("oroshi.documentation.chrome.help_tooltip") } do + icon("question-circle") + end + end + + # Generate breadcrumb items for the current documentation page + def doc_breadcrumbs + crumbs = [ + { label: t("oroshi.documentation.chrome.home"), path: documentation_index_path(locale: I18n.locale) } + ] + + if @current_section.present? + crumbs << { + label: t("oroshi.documentation.sections.#{@current_section}"), + path: documentation_section_path(section: @current_section, locale: I18n.locale) + } + end + + if @current_page.present? + crumbs << { + label: t("oroshi.documentation.pages.#{@current_section}.#{@current_page}"), + path: nil + } + end + + crumbs + end + + # Render a screenshot image with proper alt text and caption + def doc_screenshot(name, caption_key = nil) + alt = caption_key ? t(caption_key) : name.humanize + image_path = "docs/#{name}.png" + + content_tag :figure, class: "doc-screenshot my-3" do + concat image_tag(image_path, alt: alt, class: "img-fluid rounded shadow-sm border", loading: "lazy") + if caption_key + concat content_tag(:figcaption, t(caption_key), class: "text-muted small mt-1 text-center") + end + end + end + + # Render a Mermaid workflow diagram + def doc_diagram(diagram_content) + content_tag :div, class: "doc-diagram my-3", data: { + controller: "documentation-diagram", + documentation_diagram_definition_value: diagram_content + } do + content_tag :div, "", class: "mermaid" + end + end + + # Render a step-by-step workflow guide + def doc_steps(&block) + content_tag :div, class: "doc-steps", &block + end + + def doc_step(number, title_key, &block) + content_tag :div, class: "doc-step d-flex mb-3" do + concat content_tag(:div, number, class: "doc-step-number bg-primary text-white rounded-circle d-flex align-items-center justify-content-center flex-shrink-0 me-3") + concat content_tag(:div, class: "doc-step-content") { + concat content_tag(:h6, t(title_key), class: "mb-1") + concat capture(&block) if block + } + end + end + + # Render a key concept callout box + def doc_callout(type = :info, &block) + icons = { info: "info-circle-fill", tip: "lightbulb-fill", warning: "exclamation-triangle-fill", important: "exclamation-circle-fill" } + colors = { info: "primary", tip: "success", warning: "warning", important: "danger" } + + content_tag :div, class: "doc-callout alert alert-#{colors[type]} d-flex align-items-start my-3" do + concat content_tag(:div, icon(icons[type], size: 18), class: "me-2 flex-shrink-0 mt-1") + concat content_tag(:div, class: "flex-grow-1", &block) + end + end + + # Bootstrap icon name for each documentation section + def section_icon(section_key) + { + getting_started: "rocket-takeoff", + orders: "cart-check", + supply_chain: "box-seam", + production: "gear-wide-connected", + shipping: "truck", + financials: "graph-up-arrow", + admin: "sliders" + }[section_key.to_sym] || "file-text" + end + + # Language toggle preserving current page + def doc_locale_toggle + current_path = request.path + other_locale = I18n.locale == :ja ? :en : :ja + label = I18n.locale == :ja ? "English" : "日本語" + flag = I18n.locale == :ja ? "🇬🇧" : "🇯🇵" + + link_to "#{flag} #{label}", url_for(locale: other_locale), class: "btn btn-sm btn-outline-secondary" + end + end +end diff --git a/app/javascript/controllers/documentation_diagram_controller.js b/app/javascript/controllers/documentation_diagram_controller.js new file mode 100644 index 0000000..41523ef --- /dev/null +++ b/app/javascript/controllers/documentation_diagram_controller.js @@ -0,0 +1,24 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { definition: String } + + async connect() { + try { + const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs") + mermaid.initialize({ startOnLoad: false, theme: "default" }) + + const container = this.element.querySelector(".mermaid") + if (container && this.definitionValue) { + const { svg } = await mermaid.render(`mermaid-${Math.random().toString(36).slice(2)}`, this.definitionValue) + container.innerHTML = svg + } + } catch (error) { + console.warn("Mermaid diagram rendering failed:", error) + const container = this.element.querySelector(".mermaid") + if (container) { + container.innerHTML = `
${this.definitionValue}
` + } + } + } +} diff --git a/app/javascript/controllers/documentation_search_controller.js b/app/javascript/controllers/documentation_search_controller.js new file mode 100644 index 0000000..08d9d1d --- /dev/null +++ b/app/javascript/controllers/documentation_search_controller.js @@ -0,0 +1,100 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "results"] + static values = { noResults: String } + + connect() { + this.buildIndex() + this.selectedIndex = -1 + } + + buildIndex() { + this.entries = [] + let currentSection = null + + document.querySelectorAll(".doc-sidebar nav > *").forEach(el => { + if (el.classList.contains("nav-section")) { + currentSection = el.textContent.trim() + } else if (el.classList.contains("nav-link")) { + this.entries.push({ + text: el.textContent.trim().toLowerCase(), + label: el.textContent.trim(), + href: el.href, + section: currentSection, + isSubpage: el.classList.contains("ps-4") + }) + } + }) + } + + search() { + const query = this.inputTarget.value.trim().toLowerCase() + this.selectedIndex = -1 + + if (query.length < 2) { + this.hide() + return + } + + const matches = this.entries.filter(entry => entry.text.includes(query)) + + if (matches.length === 0) { + this.resultsTarget.style.display = "block" + const noResultsText = this.hasNoResultsValue ? this.noResultsValue : "No results" + this.resultsTarget.innerHTML = `
${noResultsText}
` + return + } + + this.resultsTarget.style.display = "block" + this.resultsTarget.innerHTML = matches.map((match, i) => { + const sectionBadge = match.section && match.isSubpage + ? `${match.section}` + : "" + return `${match.label}${sectionBadge}` + }).join("") + } + + keydown(event) { + const results = this.resultsTarget.querySelectorAll(".doc-search-result") + if (results.length === 0) return + + if (event.key === "ArrowDown") { + event.preventDefault() + this.selectedIndex = Math.min(this.selectedIndex + 1, results.length - 1) + this.highlightResult(results) + } else if (event.key === "ArrowUp") { + event.preventDefault() + this.selectedIndex = Math.max(this.selectedIndex - 1, 0) + this.highlightResult(results) + } else if (event.key === "Enter" && this.selectedIndex >= 0) { + event.preventDefault() + results[this.selectedIndex].click() + } else if (event.key === "Escape") { + this.hide() + this.inputTarget.blur() + } + } + + highlightResult(results) { + results.forEach((r, i) => { + r.style.background = i === this.selectedIndex ? "#e9ecef" : "" + }) + if (this.selectedIndex >= 0) { + results[this.selectedIndex].scrollIntoView({ block: "nearest" }) + } + } + + hide() { + this.resultsTarget.style.display = "none" + this.resultsTarget.innerHTML = "" + } + + clickOutside(event) { + if (!this.element.contains(event.target)) { + this.hide() + } + } +} diff --git a/app/views/layouts/documentation.html.erb b/app/views/layouts/documentation.html.erb new file mode 100644 index 0000000..c64b740 --- /dev/null +++ b/app/views/layouts/documentation.html.erb @@ -0,0 +1,114 @@ + + + + + + <%= content_for(:title) || t('oroshi.documentation.chrome.title') %> + + <%= capybara_lockstep if defined?(Capybara::Lockstep) %> + + <%= action_cable_meta_tag %> + <%= turbo_refreshes_with method: :morph, scroll: :preserve %> + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + + + + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + <%= yield :head %> + + + <%= render partial: "layouts/shared/navbar" %> + +
+ <%# Sidebar Navigation %> + + + <%# Main Content %> +
+ <%# Breadcrumbs %> + + +
+ <%= yield %> +
+
+
+ + <%= render "layouts/shared/footer" %> + + diff --git a/app/views/layouts/shared/_navbar.html.erb b/app/views/layouts/shared/_navbar.html.erb index 2fb26bc..ee14f58 100644 --- a/app/views/layouts/shared/_navbar.html.erb +++ b/app/views/layouts/shared/_navbar.html.erb @@ -29,6 +29,13 @@ <% if user_signed_in? %> <%= render 'oroshi/onboarding/checklist_dropdown' %> +