Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions app/controllers/oroshi/documentation_controller.rb
Original file line number Diff line number Diff line change
@@ -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
148 changes: 148 additions & 0 deletions app/helpers/oroshi/documentation_helper.rb
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions app/javascript/controllers/documentation_diagram_controller.js
Original file line number Diff line number Diff line change
@@ -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 = `<pre class="bg-light p-3 rounded"><code>${this.definitionValue}</code></pre>`
}
}
}
}
100 changes: 100 additions & 0 deletions app/javascript/controllers/documentation_search_controller.js
Original file line number Diff line number Diff line change
@@ -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 = `<div class="p-2 text-muted small">${noResultsText}</div>`
return
}

this.resultsTarget.style.display = "block"
this.resultsTarget.innerHTML = matches.map((match, i) => {
const sectionBadge = match.section && match.isSubpage
? `<span class="badge bg-light text-muted ms-1" style="font-size: 0.65rem;">${match.section}</span>`
: ""
return `<a href="${match.href}"
class="d-block p-2 text-decoration-none border-bottom small doc-search-result"
data-index="${i}">${match.label}${sectionBadge}</a>`
}).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()
}
}
}
Loading
Loading