inst/shiny/app.R

# ============================================================
# ConverTier2
# Converter of Assessment Scores with Tiered Instruments
#
# Authors:
# 1. Bhilan Rhiyu Antama
# 2. Heni Setiyaningsih
# 3. Edi Istiyono
# ============================================================


# ============================================================
# LIBRARIES
# ============================================================

library(shiny)
library(DT)
library(shinythemes)
library(shinyjs)
library(openxlsx)


# ============================================================
# HELPER FUNCTION
# Generate default scoring rules
# ============================================================

generate_default_rules <- function(n_tier) {

  patterns <- apply(
    expand.grid(
      rep(
        list(c(1, 0)),
        n_tier
      )
    ),
    1,
    paste0,
    collapse = ""
  )

  scores <- rev(seq_along(patterns))

  paste(
    paste0(
      patterns,
      " = ",
      scores
    ),
    collapse = "\n"
  )
}


# ============================================================
# UI
# ============================================================

ui <- fluidPage(

  # ----------------------------------------------------------
  # ShinyJS
  # ----------------------------------------------------------

  useShinyjs(),


  # ----------------------------------------------------------
  # Theme
  # ----------------------------------------------------------

  theme = shinytheme("flatly"),


  # ----------------------------------------------------------
  # HEAD
  # ----------------------------------------------------------

  tags$head(

    # Google Fonts
    tags$link(
      rel = "preconnect",
      href = "https://fonts.googleapis.com"
    ),

    tags$link(
      rel = "preconnect",
      href = "https://fonts.gstatic.com",
      crossorigin = "anonymous"
    ),

    tags$link(
      href = paste0(
        "https://fonts.googleapis.com/css2?",
        "family=JetBrains+Mono:wght@400;500;600&",
        "family=Plus+Jakarta+Sans:wght@400;500;600;700;800&",
        "display=swap"
      ),
      rel = "stylesheet"
    ),


    # ========================================================
    # CSS
    # ========================================================

    tags$style(HTML("

      /* =====================================================
         GLOBAL
         ===================================================== */

      body {

        background: #f4f7fb;

        font-family:
          'Plus Jakarta Sans',
          sans-serif;

        color: #243447;

      }


      .container-fluid {

        max-width: 1500px;

        padding:
          0 28px 40px 28px;

      }


      /* =====================================================
         HEADER
         ===================================================== */

      .app-header {

        background:
          linear-gradient(
            135deg,
            #1f4e79 0%,
            #2878b5 45%,
            #3ca6c9 100%
          );

        color: white;

        padding:
          32px 36px;

        margin:
          0 -28px 28px -28px;

        border-radius:
          0 0 22px 22px;

        box-shadow:
          0 8px 24px
          rgba(31, 78, 121, 0.20);

      }


      .app-title {

        font-size: 30px;

        font-weight: 800;

        margin:
          0 0 8px 0;

        letter-spacing:
          -0.5px;

      }


      .app-subtitle {

        font-size: 14px;

        opacity: 0.92;

        margin: 0;

        font-weight: 400;

      }


      /* =====================================================
         STEP CARDS
         ===================================================== */

      .step-card {

        background: white;

        border-radius: 16px;

        padding:
          22px 24px;

        margin-bottom: 18px;

        border:
          1px solid #e6edf4;

        box-shadow:
          0 4px 16px
          rgba(31, 78, 121, 0.06);

      }


      .step-header {

        display: flex;

        align-items: center;

        gap: 12px;

        margin-bottom: 18px;

      }


      .step-number {

        width: 34px;

        height: 34px;

        min-width: 34px;

        border-radius: 10px;

        display: flex;

        align-items: center;

        justify-content: center;

        background: #eaf4fb;

        color: #1f6f9f;

        font-size: 14px;

        font-weight: 800;

      }


      .step-title {

        font-size: 17px;

        font-weight: 700;

        color: #253b53;

        margin: 0;

      }


      .step-description {

        color: #718096;

        font-size: 13px;

        margin:
          4px 0 0 0;

      }


      /* =====================================================
         FORM
         ===================================================== */

      .form-group {

        margin-bottom: 14px;

      }


      label {

        font-size: 13px;

        font-weight: 600;

        color: #34495e;

      }


      .form-control {

        border:
          1px solid #dce5ee;

        border-radius: 9px;

        box-shadow: none;

        font-size: 13px;

      }


      .form-control:focus {

        border-color: #4ba3c7;

        box-shadow:
          0 0 0 3px
          rgba(75, 163, 199, 0.10);

      }


      textarea.form-control {

        font-family:
          'JetBrains Mono',
          monospace;

        font-size: 12px;

        line-height: 1.7;

        min-height: 190px;

      }


      /* =====================================================
         BUTTONS
         ===================================================== */

      .btn {

        border-radius:
          9px !important;

        font-weight:
          600 !important;

        font-size:
          13px !important;

        padding:
          9px 16px !important;

        opacity:
          1 !important;

        text-shadow:
          none !important;

      }


      /* -----------------------------------------------------
         PRIMARY BUTTON
         ----------------------------------------------------- */

      .btn-primary,
      .btn-primary:focus,
      .btn-primary:active {

        background:
          linear-gradient(
            135deg,
            #2878b5 0%,
            #3195c1 100%
          ) !important;

        background-image:
          linear-gradient(
            135deg,
            #2878b5 0%,
            #3195c1 100%
          ) !important;

        border:
          none !important;

        color:
          #ffffff !important;

        border-radius:
          9px !important;

        font-weight:
          700 !important;

        opacity:
          1 !important;

        box-shadow:
          0 4px 10px
          rgba(40, 120, 181, 0.20)
          !important;

      }


      .btn-primary:hover {

        background:
          linear-gradient(
            135deg,
            #1f679d 0%,
            #2878b5 100%
          ) !important;

        background-image:
          linear-gradient(
            135deg,
            #1f679d 0%,
            #2878b5 100%
          ) !important;

        color:
          #ffffff !important;

        transform:
          translateY(-1px);

        box-shadow:
          0 6px 14px
          rgba(40, 120, 181, 0.25)
          !important;

      }


      /* -----------------------------------------------------
         SUCCESS / DOWNLOAD BUTTON
         ----------------------------------------------------- */

      .btn-success,
      .btn-success:focus,
      .btn-success:active {

        background:
          linear-gradient(
            135deg,
            #2e9d73 0%,
            #38ad82 100%
          ) !important;

        background-image:
          linear-gradient(
            135deg,
            #2e9d73 0%,
            #38ad82 100%
          ) !important;

        border:
          none !important;

        color:
          #ffffff !important;

        opacity:
          1 !important;

        box-shadow:
          0 4px 10px
          rgba(46, 157, 115, 0.18)
          !important;

      }


      .btn-success:hover {

        background:
          linear-gradient(
            135deg,
            #258761 0%,
            #2e9d73 100%
          ) !important;

        background-image:
          linear-gradient(
            135deg,
            #258761 0%,
            #2e9d73 100%
          ) !important;

        color:
          #ffffff !important;

        transform:
          translateY(-1px);

      }


      /* -----------------------------------------------------
         DEFAULT / BROWSE BUTTON
         ----------------------------------------------------- */

      .btn-default,
      .btn-default:focus,
      .btn-default:active {

        background:
          #f5f7fa !important;

        background-image:
          none !important;

        border:
          1px solid #dce5ee !important;

        color:
          #34495e !important;

        opacity:
          1 !important;

        box-shadow:
          none !important;

      }


      .btn-default:hover {

        background:
          #eaf0f5 !important;

        border-color:
          #cbd8e3 !important;

        color:
          #253b53 !important;

      }


      /* -----------------------------------------------------
         DISABLED BUTTON
         ----------------------------------------------------- */

      .btn:disabled,
      .btn.disabled {

        opacity:
          0.55 !important;

        cursor:
          not-allowed;

      }


      /* -----------------------------------------------------
         FILE INPUT
         ----------------------------------------------------- */

      .form-control[readonly] {

        background:
          #ffffff !important;

        color:
          #607285 !important;

        opacity:
          1 !important;

      }


      .shiny-input-container {

        width: 100%;

      }


      /* =====================================================
         RULE EDITOR
         ===================================================== */

      .template-box {

        background:
          #f7fafc;

        border:
          1px dashed #c9d7e5;

        border-radius:
          10px;

        padding:
          12px 14px;

        margin-bottom:
          12px;

        font-family:
          'JetBrains Mono',
          monospace;

        font-size:
          11px;

        color:
          #617386;

      }


      .template-title {

        font-family:
          'Plus Jakarta Sans',
          sans-serif;

        font-weight:
          700;

        font-size:
          12px;

        color:
          #4a5d70;

        margin-bottom:
          6px;

      }


      /* =====================================================
         STATUS
         ===================================================== */

      .status-bar {

        display:
          none;

        border-radius:
          9px;

        padding:
          10px 13px;

        margin-top:
          12px;

        font-size:
          12px;

        font-weight:
          500;

      }


      .status-success {

        display:
          block;

        background:
          #eaf8f1;

        color:
          #237653;

        border:
          1px solid #c8eadb;

      }


      .status-error {

        display:
          block;

        background:
          #fff0f0;

        color:
          #b43b3b;

        border:
          1px solid #f2cccc;

      }


      .status-info {

        display:
          block;

        background:
          #edf7fc;

        color:
          #256a8d;

        border:
          1px solid #cde6f2;

      }


      /* =====================================================
         RULE PREVIEW
         ===================================================== */

      .rule-preview {

        background:
          #f8fafc;

        border-radius:
          12px;

        padding:
          14px;

        max-height:
          430px;

        overflow-y:
          auto;

      }


      .rule-row {

        display:
          flex;

        align-items:
          center;

        padding:
          8px 4px;

        border-bottom:
          1px solid #edf1f5;

      }


      .rule-row:last-child {

        border-bottom:
          none;

      }


      .rule-pattern {

        flex: 1;

        font-family:
          'JetBrains Mono',
          monospace;

        font-size:
          12px;

        font-weight:
          600;

      }


      .rule-score {

        width:
          46px;

        height:
          30px;

        border-radius:
          8px;

        display:
          flex;

        align-items:
          center;

        justify-content:
          center;

        background:
          #eaf4fb;

        color:
          #21658e;

        font-size:
          12px;

        font-weight:
          700;

      }


      .tier-badge {

        display:
          inline-block;

        border-radius:
          5px;

        padding:
          3px 6px;

        margin-right:
          3px;

        background:
          #edf2f7;

        color:
          #536576;

        font-size:
          10px;

        font-weight:
          700;

      }


      /* =====================================================
         DATA INFORMATION
         ===================================================== */

      .data-info {

        display:
          flex;

        gap:
          10px;

        flex-wrap:
          wrap;

        margin-top:
          12px;

      }


      .info-chip {

        background:
          #f4f7fa;

        border:
          1px solid #e2e8f0;

        border-radius:
          8px;

        padding:
          7px 11px;

        font-size:
          11px;

        color:
          #607285;

      }


      .info-chip strong {

        color:
          #263c51;

      }


      /* =====================================================
         DATATABLE
         ===================================================== */

      .dataTables_wrapper {

        font-size:
          12px;

      }


      table.dataTable thead th {

        background:
          #f4f7fb !important;

        color:
          #3d5267 !important;

        border-bottom:
          1px solid #dce5ee !important;

        font-weight:
          700 !important;

      }


      table.dataTable tbody td {

        vertical-align:
          middle;

      }


      /* =====================================================
         FOOTER
         ===================================================== */

      .app-footer {

        text-align:
          center;

        color:
          #8a99a8;

        font-size:
          11px;

        margin-top:
          24px;

        padding-top:
          16px;

        border-top:
          1px solid #e5ebf1;

      }


      /* =====================================================
         RESPONSIVE
         ===================================================== */

      @media (max-width: 991px) {

        .container-fluid {

          padding:
            0 15px 30px 15px;

        }

        .app-header {

          margin:
            0 -15px 20px -15px;

          padding:
            26px 22px;

        }

        .app-title {

          font-size:
            23px;

        }

        .step-card {

          padding:
            18px;

        }

      }

    "))

  ),


  # ==========================================================
  # HEADER
  # ==========================================================

  div(
    class = "app-header",

    h1(
      class = "app-title",
      "Converter of Assessment Scores with Tiered Instruments"
    ),

    p(
      class = "app-subtitle",
      "Konverter skor 0/1 per jawaban & alasan sesuai pedoman penskoran Anda"
    )
  ),


  # ==========================================================
  # MAIN
  # ==========================================================

  fluidRow(


    # ========================================================
    # LEFT COLUMN
    # ========================================================

    column(
      width = 7,


      # ======================================================
      # STEP 1
      # ======================================================

      div(
        class = "step-card",

        div(
          class = "step-header",

          div(
            class = "step-number",
            "1"
          ),

          div(

            h3(
              class = "step-title",
              "Tentukan Jumlah Tier"
            ),

            p(
              class = "step-description",
              "Pilih jumlah komponen respons pada setiap butir."
            )

          )

        ),


        selectInput(

          inputId = "n_tier",

          label = "Jumlah tier:",

          choices = c(
            "2 Tier" = 2,
            "3 Tier" = 3,
            "4 Tier" = 4
          ),

          selected = 2,

          width = "100%"

        )

      ),


      # ======================================================
      # STEP 2
      # ======================================================

      div(
        class = "step-card",

        div(
          class = "step-header",

          div(
            class = "step-number",
            "2"
          ),

          div(

            h3(
              class = "step-title",
              "Atur Pedoman Penskoran"
            ),

            p(
              class = "step-description",
              "Tentukan skor untuk setiap kombinasi respons."
            )

          )

        ),


        div(
          class = "template-box",

          div(
            class = "template-title",
            "Format aturan"
          ),

          textOutput(
            "template_text",
            inline = TRUE
          )

        ),


        textAreaInput(

          inputId = "rules_text",

          label = "Aturan penskoran:",

          value =
            generate_default_rules(2),

          rows = 10,

          width = "100%"

        ),


        actionButton(

          inputId = "apply_rules",

          label = "✔ Terapkan Aturan",

          class = "btn-primary"

        ),


        div(
          id = "rules_status",
          class = "status-bar"
        )

      ),


      # ======================================================
      # STEP 3
      # ======================================================

      div(
        class = "step-card",

        div(
          class = "step-header",

          div(
            class = "step-number",
            "3"
          ),

          div(

            h3(
              class = "step-title",
              "Unggah Data"
            ),

            p(
              class = "step-description",
              "Unggah file Excel dengan respons biner 0/1."
            )

          )

        ),


        fileInput(

          inputId = "file",

          label = "Pilih file Excel:",

          accept = c(
            ".xlsx",
            ".xls"
          ),

          width = "100%"

        ),


        div(
          class = "template-box",

          div(
            class = "template-title",
            "Format kolom yang diharapkan"
          ),

          HTML(
            "<b>Jwb_1</b> | <b>Als_1</b> |
             <b>Jwb_2</b> | <b>Als_2</b> | ..."
          ),

          br(),

          span(
            "Semua nilai respons harus berupa 0 atau 1."
          )

        ),


        div(
          id = "upload_status",
          class = "status-bar"
        ),


        uiOutput(
          "data_info"
        )

      ),


      # ======================================================
      # STEP 4
      # ======================================================

      div(
        class = "step-card",

        div(
          class = "step-header",

          div(
            class = "step-number",
            "4"
          ),

          div(

            h3(
              class = "step-title",
              "Proses & Unduh"
            ),

            p(
              class = "step-description",
              "Konversi respons menjadi skor berdasarkan aturan aktif."
            )

          )

        ),


        actionButton(

          inputId = "process",

          label = "⚙ Proses Konversi",

          class = "btn-primary"

        ),


        downloadButton(

          outputId = "download",

          label = "⬇ Unduh Hasil Excel",

          class = "btn-success"

        ),


        div(
          id = "process_status",
          class = "status-bar"
        )

      )

    ),


    # ========================================================
    # RIGHT COLUMN
    # ========================================================

    column(
      width = 5,


      # ======================================================
      # ACTIVE RULES
      # ======================================================

      div(
        class = "step-card",

        div(
          class = "step-header",

          div(
            class = "step-number",
            "✓"
          ),

          div(

            h3(
              class = "step-title",
              "Pedoman Penskoran Aktif"
            ),

            p(
              class = "step-description",
              "Aturan yang akan digunakan dalam proses konversi."
            )

          )

        ),


        uiOutput(
          "rules_preview"
        )

      ),


      # ======================================================
      # RESULT PREVIEW
      # ======================================================

      div(
        class = "step-card",

        div(
          class = "step-header",

          div(
            class = "step-number",
            "✓"
          ),

          div(

            h3(
              class = "step-title",
              "Preview Hasil"
            ),

            p(
              class = "step-description",
              "Pratinjau skor butir setelah proses konversi."
            )

          )

        ),


        DTOutput(
          "result_preview"
        )

      )

    )

  ),


  # ==========================================================
  # FOOTER
  # ==========================================================

  div(
    class = "app-footer",

    "ConverTier2 • Converter of Assessment Scores with Tiered Instruments",

    br(),

    "Bhilan Rhiyu Antama • Heni Setiyaningsih • Edi Istiyono"

  )

)


# ============================================================
# SERVER
# ============================================================

server <- function(input, output, session) {


  # ==========================================================
  # REACTIVE VALUES
  # ==========================================================

  rv <- reactiveValues(

    rules = NULL,

    raw_data = NULL,

    scored_data = NULL

  )


  # ==========================================================
  # STATUS HELPER
  # ==========================================================

  set_status <- function(
    id,
    type,
    message
  ) {

    shinyjs::removeClass(
      id,
      c(
        "status-success",
        "status-error",
        "status-info"
      )
    )


    shinyjs::addClass(
      id,
      paste0(
        "status-",
        type
      )
    )


    shinyjs::html(
      id,
      message
    )

  }


  # ==========================================================
  # STEP 1
  # TIER CHANGE
  # ==========================================================

  observeEvent(

    input$n_tier,

    {

      nt <-
        as.integer(
          input$n_tier
        )


      updateTextAreaInput(

        session = session,

        inputId =
          "rules_text",

        value =
          generate_default_rules(nt)

      )


      rv$rules <-
        NULL


      rv$scored_data <-
        NULL


      shinyjs::disable(
        "download"
      )


      output$result_preview <-
        renderDT(NULL)


      output$rules_preview <-
        renderUI({

          div(

            class =
              "rule-preview",

            div(

              style = "
                color:#8a99a8;
                text-align:center;
                padding:30px 10px;
                font-size:12px;
              ",

              "Belum ada aturan yang diterapkan."

            )

          )

        })

    },

    ignoreInit = TRUE

  )


  # ==========================================================
  # TEMPLATE TEXT
  # ==========================================================

  output$template_text <-
    renderText({

      nt <-
        as.integer(
          input$n_tier
        )


      paste0(

        paste(

          paste0(
            "Tier",
            seq_len(nt)
          ),

          collapse =
            " | "

        ),

        "  =  skor"

      )

    })


  # ==========================================================
  # STEP 2
  # APPLY RULES
  # ==========================================================

  observeEvent(

    input$apply_rules,

    {

      nt <-
        as.integer(
          input$n_tier
        )


      result <-
        tryCatch(

          {

            rules <-
              ConverTier2::parse_rules(
                input$rules_text
              )


            # ----------------------------------------------
            # Check pattern length
            # ----------------------------------------------

            bad_length <-
              names(rules)[
                nchar(
                  names(rules)
                ) != nt
              ]


            if (
              length(bad_length) > 0
            ) {

              stop(

                paste0(

                  "Panjang pola berikut tidak sesuai ",

                  "dengan jumlah tier (",

                  nt,

                  "): ",

                  paste(
                    bad_length,
                    collapse = ", "
                  )

                )

              )

            }


            rv$rules <-
              rules


            rv$scored_data <-
              NULL


            shinyjs::disable(
              "download"
            )


            list(
              success = TRUE,
              rules = rules
            )

          },


          error = function(e) {

            list(

              success = FALSE,

              message =
                conditionMessage(e)

            )

          }

        )


      # ------------------------------------------------------
      # SUCCESS
      # ------------------------------------------------------

      if (
        result$success
      ) {

        set_status(

          "rules_status",

          "success",

          paste0(

            "✓ Aturan berhasil diterapkan. ",

            length(rv$rules),

            " aturan aktif."

          )

        )

      }


      # ------------------------------------------------------
      # ERROR
      # ------------------------------------------------------

      else {

        rv$rules <-
          NULL


        set_status(

          "rules_status",

          "error",

          paste0(

            "✕ ",

            result$message

          )

        )

      }

    }

  )


  # ==========================================================
  # ACTIVE RULE PREVIEW
  # ==========================================================

  output$rules_preview <-
    renderUI({

      req(
        rv$rules
      )


      rules <-
        rv$rules


      nt <-
        as.integer(
          input$n_tier
        )


      rows <-
        lapply(

          seq_along(rules),

          function(i) {

            pattern <-
              names(rules)[i]


            badges <-
              lapply(

                seq_len(nt),

                function(j) {

                  span(

                    class =
                      "tier-badge",

                    paste0(

                      "T",

                      j,

                      ": ",

                      substr(

                        pattern,

                        j,

                        j

                      )

                    )

                  )

                }

              )


            div(

              class =
                "rule-row",


              div(

                class =
                  "rule-pattern",

                badges

              ),


              div(

                class =
                  "rule-score",

                as.character(
                  rules[i]
                )

              )

            )

          }

        )


      div(

        class =
          "rule-preview",

        rows

      )

    })


  # ==========================================================
  # STEP 3
  # UPLOAD DATA
  # ==========================================================

  observeEvent(

    input$file,

    {

      rv$raw_data <-
        NULL


      rv$scored_data <-
        NULL


      shinyjs::disable(
        "download"
      )


      if (
        is.null(input$file)
      ) {

        return()

      }


      result <-
        tryCatch(

          {

            data <-
              readxl::read_excel(
                input$file$datapath
              )


            data <-
              as.data.frame(
                data
              )


            if (

              nrow(data) == 0 ||

              ncol(data) == 0

            ) {

              stop(
                "File tidak berisi data."
              )

            }


            rv$raw_data <-
              data


            list(

              success = TRUE,

              data = data

            )

          },


          error = function(e) {

            list(

              success = FALSE,

              message =
                conditionMessage(e)

            )

          }

        )


      # ------------------------------------------------------
      # SUCCESS
      # ------------------------------------------------------

      if (
        result$success
      ) {

        set_status(

          "upload_status",

          "success",

          paste0(

            "✓ File berhasil dibaca: ",

            nrow(
              rv$raw_data
            ),

            " responden × ",

            ncol(
              rv$raw_data
            ),

            " kolom."

          )

        )

      }


      # ------------------------------------------------------
      # ERROR
      # ------------------------------------------------------

      else {

        rv$raw_data <-
          NULL


        set_status(

          "upload_status",

          "error",

          paste0(

            "✕ ",

            result$message

          )

        )

      }

    }

  )


  # ==========================================================
  # DATA INFORMATION
  # ==========================================================

  output$data_info <-
    renderUI({

      req(
        rv$raw_data
      )


      div(

        class =
          "data-info",


        div(

          class =
            "info-chip",

          strong(
            "Responden: "
          ),

          nrow(
            rv$raw_data
          )

        ),


        div(

          class =
            "info-chip",

          strong(
            "Kolom: "
          ),

          ncol(
            rv$raw_data
          )

        ),


        div(

          class =
            "info-chip",

          strong(
            "Tier: "
          ),

          input$n_tier

        ),


        div(

          class =
            "info-chip",

          strong(
            "Butir: "
          ),

          ncol(
            rv$raw_data
          ) /

            as.integer(
              input$n_tier
            )

        )

      )

    })


  # ==========================================================
  # STEP 4
  # PROCESS
  # ==========================================================

  observeEvent(

    input$process,

    {

      # ------------------------------------------------------
      # RULE CHECK
      # ------------------------------------------------------

      if (
        is.null(rv$rules)
      ) {

        set_status(

          "process_status",

          "error",

          "✕ Terapkan pedoman penskoran terlebih dahulu."

        )

        return()

      }


      # ------------------------------------------------------
      # DATA CHECK
      # ------------------------------------------------------

      if (
        is.null(rv$raw_data)
      ) {

        set_status(

          "process_status",

          "error",

          "✕ Unggah file Excel terlebih dahulu."

        )

        return()

      }


      # ------------------------------------------------------
      # PROCESS
      # ------------------------------------------------------

      result <-
        tryCatch(

          {

            nt <-
              as.integer(
                input$n_tier
              )


            # ----------------------------------------------
            # Package validation
            # ----------------------------------------------

            ConverTier2::validate_data(

              data =
                rv$raw_data,

              n_tier =
                nt

            )


            # ----------------------------------------------
            # Package conversion
            # ----------------------------------------------

            scored <-
              ConverTier2::convert_tier(

                data =
                  rv$raw_data,

                rules =
                  rv$rules,

                n_tier =
                  nt

              )


            rv$scored_data <-
              scored


            list(

              success = TRUE,

              data = scored

            )

          },


          error = function(e) {

            list(

              success = FALSE,

              message =
                conditionMessage(e)

            )

          }

        )


      # ------------------------------------------------------
      # SUCCESS
      # ------------------------------------------------------

      if (
        result$success
      ) {

        shinyjs::enable(
          "download"
        )


        set_status(

          "process_status",

          "success",

          paste0(

            "✓ Konversi berhasil. ",

            nrow(
              rv$scored_data
            ),

            " responden × ",

            ncol(
              rv$scored_data
            ),

            " butir berhasil diproses."

          )

        )

      }


      # ------------------------------------------------------
      # ERROR
      # ------------------------------------------------------

      else {

        rv$scored_data <-
          NULL


        shinyjs::disable(
          "download"
        )


        set_status(

          "process_status",

          "error",

          paste0(

            "✕ ",

            result$message

          )

        )

      }

    }

  )


  # ==========================================================
  # RESULT PREVIEW
  # ==========================================================

  output$result_preview <-
    renderDT({

      req(
        rv$scored_data
      )


      datatable(

        rv$scored_data,

        rownames =
          FALSE,


        options = list(

          pageLength =
            8,

          scrollX =
            TRUE,

          autoWidth =
            TRUE,

          lengthMenu =
            c(
              5,
              8,
              10,
              25,
              50
            )

        )

      )

    })


  # ==========================================================
  # DOWNLOAD EXCEL
  # ==========================================================

  output$download <-
    downloadHandler(


      # ------------------------------------------------------
      # Filename
      # ------------------------------------------------------

      filename = function() {

        paste0(

          "hasil_skor_tier2_",

          format(

            Sys.time(),

            "%Y%m%d_%H%M%S"

          ),

          ".xlsx"

        )

      },


      # ------------------------------------------------------
      # Content
      # ------------------------------------------------------

      content = function(file) {

        req(
          rv$scored_data
        )

        req(
          rv$rules
        )


        # ====================================================
        # CREATE WORKBOOK
        # ====================================================

        wb <-
          createWorkbook()


        # ====================================================
        # STYLES
        # ====================================================

        header_style <-
          createStyle(

            fontName =
              "Arial",

            fontSize =
              10,

            textDecoration =
              "bold",

            halign =
              "center",

            valign =
              "center",

            fgFill =
              "#2878B5",

            fontColour =
              "#FFFFFF",

            border =
              "TopBottomLeftRight",

            borderColour =
              "#D9E2EC"

          )


        body_style <-
          createStyle(

            fontName =
              "Arial",

            fontSize =
              10,

            valign =
              "center"

          )


        # ====================================================
        # SHEET 1
        # SKOR BUTIR
        # ====================================================

        addWorksheet(

          wb,

          "Skor Butir"

        )


        writeData(

          wb,

          sheet =
            "Skor Butir",

          x =
            rv$scored_data,

          startRow =
            1,

          startCol =
            1,

          withFilter =
            TRUE

        )


        # Header
        addStyle(

          wb,

          sheet =
            "Skor Butir",

          style =
            header_style,

          rows =
            1,

          cols =
            seq_len(
              ncol(
                rv$scored_data
              )
            ),

          gridExpand =
            TRUE

        )


        # Body
        if (

          nrow(
            rv$scored_data
          ) > 0

        ) {

          addStyle(

            wb,

            sheet =
              "Skor Butir",

            style =
              body_style,

            rows =
              2:(

                nrow(
                  rv$scored_data
                ) + 1

              ),

            cols =
              seq_len(
                ncol(
                  rv$scored_data
                )
              ),

            gridExpand =
              TRUE

          )

        }


        # Column widths
        setColWidths(

          wb,

          sheet =
            "Skor Butir",

          cols =
            seq_len(
              ncol(
                rv$scored_data
              )
            ),

          widths =
            "auto"

        )


        # Freeze header
        freezePane(

          wb,

          sheet =
            "Skor Butir",

          firstActiveRow =
            2

        )


        # ====================================================
        # SHEET 2
        # INFO PENSKORAN
        # ====================================================

        addWorksheet(

          wb,

          "Info Penskoran"

        )


        info <-
          data.frame(

            Informasi = c(

              "Nama Aplikasi",

              "Jumlah Tier",

              "Jumlah Responden",

              "Jumlah Butir",

              "Jumlah Aturan",

              "Tanggal Pemrosesan"

            ),


            Nilai = c(

              "ConverTier2",

              as.character(
                input$n_tier
              ),

              nrow(
                rv$raw_data
              ),

              ncol(
                rv$scored_data
              ),

              length(
                rv$rules
              ),

              format(

                Sys.time(),

                "%Y-%m-%d %H:%M:%S"

              )

            ),


            stringsAsFactors =
              FALSE

          )


        writeData(

          wb,

          sheet =
            "Info Penskoran",

          x =
            info,

          startRow =
            1,

          startCol =
            1

        )


        addStyle(

          wb,

          sheet =
            "Info Penskoran",

          style =
            header_style,

          rows =
            1,

          cols =
            1:2,

          gridExpand =
            TRUE

        )


        addStyle(

          wb,

          sheet =
            "Info Penskoran",

          style =
            body_style,

          rows =
            2:(

              nrow(info) + 1

            ),

          cols =
            1:2,

          gridExpand =
            TRUE

        )


        # ====================================================
        # SCORING RULES
        # ====================================================

        start_row <-
          nrow(info) + 4


        writeData(

          wb,

          sheet =
            "Info Penskoran",

          x =
            "Pedoman Penskoran",

          startRow =
            start_row,

          startCol =
            1

        )


        addStyle(

          wb,

          sheet =
            "Info Penskoran",

          style =
            header_style,

          rows =
            start_row,

          cols =
            1:2,

          gridExpand =
            TRUE

        )


        rules_df <-
          data.frame(

            Pola =
              names(
                rv$rules
              ),

            Skor =
              as.numeric(
                rv$rules
              ),

            stringsAsFactors =
              FALSE

          )


        writeData(

          wb,

          sheet =
            "Info Penskoran",

          x =
            rules_df,

          startRow =
            start_row + 1,

          startCol =
            1,

          colNames =
            TRUE

        )


        addStyle(

          wb,

          sheet =
            "Info Penskoran",

          style =
            header_style,

          rows =
            start_row + 1,

          cols =
            1:2,

          gridExpand =
            TRUE

        )


        if (

          nrow(
            rules_df
          ) > 0

        ) {

          addStyle(

            wb,

            sheet =
              "Info Penskoran",

            style =
              body_style,

            rows = (

              start_row + 2

            ): (

              start_row +
                nrow(rules_df) +
                1

            ),

            cols =
              1:2,

            gridExpand =
              TRUE

          )

        }


        setColWidths(

          wb,

          sheet =
            "Info Penskoran",

          cols =
            1:2,

          widths =
            c(
              28,
              18
            )

        )


        freezePane(

          wb,

          sheet =
            "Info Penskoran",

          firstActiveRow =
            2

        )


        # ====================================================
        # SAVE
        # ====================================================

        saveWorkbook(

          wb,

          file =
            file,

          overwrite =
            TRUE

        )

      }

    )


  # ==========================================================
  # INITIAL STATE
  # ==========================================================

  shinyjs::disable(
    "download"
  )

}


# ============================================================
# RUN APPLICATION
# ============================================================

shinyApp(
  ui = ui,
  server = server
)

Try the ConverTier2 package in your browser

Any scripts or data that you put into this service are public.

ConverTier2 documentation built on Nov. 12, 2026, 1:07 a.m.