{"id":50978441,"url":"https://github.com/hectorqlucero/cgen","last_synced_at":"2026-06-19T11:04:49.092Z","repository":{"id":362437218,"uuid":"1259047105","full_name":"hectorqlucero/cgen","owner":"hectorqlucero","description":"cgen — Full-stack Clojure CRUD scaffold for admin panels, dashboards, and internal tools. One lein scaffold reads your SQLite schema and generates entity-driven grids, forms, subgrids (OTM/O2O/M2M), search, sort, pagination, and i18n — all server-rendered with Bootstrap 5 and zero JavaScript frameworks. Zero jQuery, zero npm, zero fatigue.","archived":false,"fork":false,"pushed_at":"2026-06-13T21:10:26.000Z","size":1226,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-06-13T23:12:45.460Z","etag":null,"topics":["admin-panel","bootstrap","clojure","crud","internal-tools","mysql","postgresql","ring","scaffold","sqlite"],"latest_commit_sha":null,"homepage":"","language":"Clojure","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/hectorqlucero.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-04T06:34:36.000Z","updated_at":"2026-06-13T21:10:30.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/hectorqlucero/cgen","commit_stats":null,"previous_names":["hectorqlucero/cgen"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/hectorqlucero/cgen","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hectorqlucero%2Fcgen","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hectorqlucero%2Fcgen/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hectorqlucero%2Fcgen/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hectorqlucero%2Fcgen/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/hectorqlucero","download_url":"https://codeload.github.com/hectorqlucero/cgen/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hectorqlucero%2Fcgen/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34528148,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-19T02:00:06.005Z","response_time":61,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["admin-panel","bootstrap","clojure","crud","internal-tools","mysql","postgresql","ring","scaffold","sqlite"],"created_at":"2026-06-19T11:04:48.904Z","updated_at":"2026-06-19T11:04:49.072Z","avatar_url":"https://github.com/hectorqlucero.png","language":"Clojure","funding_links":[],"categories":[],"sub_categories":[],"readme":"# cgen\n\n![cgen admin panel](screenshot.png)\n\nA parameter-driven Clojure web framework. Application behavior is controlled entirely by EDN configuration files in `resources/entities/`. Edit a config file and refresh the browser — no server restart required during development.\n\n\u003e **👋 For new developers:** This framework lets you build database-driven web apps by editing configuration files — no need to write backend code for basic features. If you know basic SQL (database queries) and can edit text files, you can be productive from day one. See [Before You Start](#before-you-start) for what you need installed.\n\n---\n\n## Table of Contents\n\n- [Before You Start](#before-you-start)\n- [Quick Start](#quick-start)\n- [Project Structure](#project-structure)\n- [Configuration Reference](#configuration-reference)\n- [Database Commands](#database-commands)\n- [Development Commands](#development-commands)\n- [Entity Configuration](#entity-configuration-the-core-of-the-framework)\n- [Adding an Entity](#adding-an-entity)\n- [Included Example Entities](#included-example-entities)\n- [Custom MVC Handlers](#custom-mvc-handlers)\n- [Manual Routes](#manual-routes)\n- [Menu Customization](#menu-customization)\n- [Creating a Report](#creating-a-report)\n- [Creating a User and Assigning a Temporary Password](#creating-a-user-and-assigning-a-temporary-password)\n- [Lifecycle Hooks](#lifecycle-hooks)\n- [Audit Trail](#audit-trail-who-did-what-and-when)\n- [TabGrid Interface](#tabgrid-interface)\n- [Internationalization (i18n)](#internationalization-i18n)\n- [Entity Dashboards (Auto-Generated)](#entity-dashboards-auto-generated)\n- [Full Tutorial: Building a Pizza Delivery App](#full-tutorial-building-a-pizza-delivery-app)\n- [Cheat Sheet](#cheat-sheet-experienced-devs)\n- [Troubleshooting](#troubleshooting)\n- [License](#license)\n\n---\n\n## Before You Start\n\nYou need these tools installed on your computer:\n\n| Tool | What it is | Where to get it |\n|---|---|---|\n| **Java 11+** | The runtime that runs Clojure code | [adoptium.net](https://adoptium.net) |\n| **Leiningen** (`lein`) | Clojure's build tool (like `npm` for Node or `pip` for Python) | [leiningen.org](https://leiningen.org) |\n\nCheck they work by running these commands in your terminal:\n\n```bash\njava -version\nlein version\n```\n\nIf you see version numbers, you're ready to go.\n\n### Key concepts you'll see in this guide\n\n| Term | What it means |\n|---|---|\n| **EDN** | Clojure's configuration format (like JSON but with `:` prefixes on keys). You write entity configs in `.edn` files. |\n| **Entity** | A database table and all its configuration (fields, permissions, menus). One `.edn` file = one entity. |\n| **SQL** | The language databases speak. You'll write simple SQL queries for your entities. |\n| **Migration** | A SQL file that creates or changes a database table. |\n| **CRUD** | **C**reate, **R**ead, **U**pdate, **D**elete — the four basic operations on a database record. |\n| **FK** | Foreign Key — a column that links one table to another (e.g., `department_id` links an employee to a department). |\n| **Parameter-driven** | The engine reads your config files and auto-generates the admin UI. You don't write HTML or JavaScript. |\n\n## Quick Start\n\n### 1. Create a new project\n\nOpen a terminal and run these commands one by one:\n\n```bash\ngit clone https://github.com/\u003cuser\u003e/cgen.git\ncd cgen\nlein setup my-project\ncd my-project\nlein with-profile dev run\n```\n\n**What this does:** It copies the framework into a new folder called `my-project/`, sets up the database with example tables, creates default user accounts, and starts the development web server.\n\n\u003e 💡 **Tip:** Replace `my-project` with your project name. To put it somewhere specific: `lein setup /path/to/my-project`\n\n### 2. Open the app in your browser\n\nOnce the server is running, open `http://localhost:3000` in your browser. Log in with one of these accounts:\n\n| Email | Password | Level | What they can do |\n|---|---|---|---|---|\n| `user@example.com` | `user` | **U** (User) | View records only |\n| `admin@example.com` | `admin` | **A** (Admin) | Create, view, edit, delete records |\n| `system@example.com` | `system` | **S** (System) | Everything |\n\n\u003e **Change all passwords before deploying to production.**\n\n### 3. How development works\n\nThe dev server **hot-reloads** your changes automatically. This means:\n- Edit any configuration file in `resources/entities/`\n- Wait 2 seconds or refresh your browser\n- Your changes appear immediately — **no server restart needed**\n\nHot-reload also applies to hook files (business logic files in `src/your-project/hooks/`).\n\nTo force an immediate reload without waiting:\n\n```bash\n# In your browser, visit:\nhttp://localhost:3000/admin/reload-config\n\n# Or from the REPL:\n```\n\n```clojure\n(require '[my-project.engine.config :as config])\n(config/reload-all!)\n```\n\n---\n\n## Project Structure (Where Things Go)\n\nHere's a tour of the files and folders in your generated project. You'll mostly work in `resources/` and occasionally in `src/`.\n\n```\nresources/                          ← Files you edit most often\n  entities/                           Entity configs (.edn files, one per database table)\n  migrations/                         Database migration files (SQL to create/change tables)\n  config/\n    app-config.edn                    Main app settings (database, email, themes, etc.)\n  i18n/\n    en.edn                            English labels/translations\n    es.edn                            Spanish labels/translations\n  public/                             Images, CSS, and JavaScript files\n\nsrc/\u003cproject-name\u003e/\n  core.clj                            App startup — wires everything together\n  layout.clj                          Page layout (header, nav bar, footer, theme)\n  menu.clj                            **Menu customization** — add/edit nav links and dropdowns\n  hooks/                              Business logic files (one per entity, optional)\n  routes/\n    routes.clj                        **Public routes** — pages anyone can visit (login, etc.)\n    proutes.clj                       **Protected routes** — pages that need login\n  handlers/                           Custom page handlers (for pages beyond basic CRUD)\n  engine/                             Framework internals (you rarely touch these)\n\ntarget/                               Compiled code (auto-generated, ignore it)\ntest/                                 Unit tests\n```\n\n**Your workflow as a developer:**\n\n1. Create a database table → write a migration in `resources/migrations/`\n2. Configure the admin UI → create/edit an `.edn` file in `resources/entities/`\n3. Add custom business logic → write a hook in `src/\u003cproject\u003e/hooks/`\n4. Build custom pages → create a handler in `src/\u003cproject\u003e/handlers/`\n5. Add nav links → edit `src/\u003cproject\u003e/menu.clj`\n\n---\n\n## Configuration Reference (app-config.edn)\n\nThis is your main app settings file at `resources/config/app-config.edn`. You only need to change a few values to get started — most defaults work out of the box. Here's the full file with explanations:\n\n```clojure\n{:app {:session-timeout 28800                    ; 8 hours in seconds\n       :default-locale :es\n       :cookie-name \"LS\"\n       :max-file-size-mb 5\n       :grid-display-limit 7\n       :pagination-size 25}\n\n :database {:error-codes\n            {:postgres {:unique \"23505\" :fk \"23503\" :not-null \"23502\"}\n             :mysql    {:unique 1062    :fk 1451   :not-null 1048}\n             :sqlite   {:unique \"UNIQUE constraint failed\"\n                        :fk \"foreign key constraint failed\"}}\n            :connection-params\n            {:mysql    {:use-ssl false :server-timezone \"UTC\"}\n             :postgres {:sslmode \"disable\"}\n             :sqlite   {}}}\n\n :ui {:themes [\"cerulean\" \"cosmo\" \"darkly\" \"flatly\" \"sandstone\" \"sketchy\" ...]\n      :default-theme \"sandstone\"}\n\n :security {:csrf-token-name \"anti-forgery-token\"\n            :session-secret-key \"rs-session-key\"\n            :allowed-file-types [\"image/jpeg\" \"image/png\" \"image/gif\" \"application/pdf\"]\n            :max-login-attempts 5\n            :lockout-duration 900}\n\n :routes {:login \"/home/login\"\n          :logout \"/home/logoff\"\n          :password-change \"/change/password\"\n          :admin-prefix \"/admin\"\n          :api-prefix \"/api\"\n          :upload-prefix \"/uploads\"}\n\n :roles {:hierarchy [\"S\" \"A\" \"U\"]\n         :labels {\"S\" \"System\" \"A\" \"Administrator\" \"U\" \"User\"}\n         :permissions {:S [:all]\n                       :A [:create :read :update :delete]\n                       :U [:read]}}\n\n :connections {:sqlite   {:db-type \"sqlite\"\n                          :db-class \"org.sqlite.JDBC\"\n                          :db-name \"db/my-project.sqlite\"}\n               :mysql    {:db-type \"mysql\"\n                          :db-class \"com.mysql.cj.jdbc.Driver\"\n                          :db-name \"//localhost:3306/my-project\"\n                          :db-user \"root\"\n                          :db-pwd \"change_me\"}\n               :postgres {:db-type \"postgresql\"\n                          :db-class \"org.postgresql.Driver\"\n                          :db-name \"//localhost:5432/my-project\"\n                          :db-user \"postgres\"\n                          :db-pwd \"change_me\"}\n               :main :sqlite         ; Used for migrations (lein migrate)\n               :default :sqlite}     ; Used by the application\n\n :site-name    \"my-project\"\n :company-name \"change_me\"\n :port         3000\n :tz           \"US/Pacific\"\n :base-url     \"http://localhost:3000/\"\n :img-url      \"http://localhost:3000/uploads/\"\n :path         \"/uploads/\"\n :uploads      \"./uploads/my-project/\"\n :max-upload-mb 5\n :allowed-image-exts [\"jpg\" \"jpeg\" \"png\" \"gif\" \"bmp\" \"webp\"]\n\n :email-host   \"change_me\"   ; smtp.provider.com\n :email-port   465\n :email-user   \"change_me\"   ; sender@example.com\n :email-passwd \"change_me\"   ; the SMTP password\n :email-ssl    true}\n```\n\nOnly three keys must change per environment: `:default` (app DB), `:main` (migration DB), and the corresponding connection detail under the connection name.\n\n---\n\n## Database Commands\n\nThese are commands you run in your project folder to manage the database:\n\n| Command | What it does |\n|---|---|\n| `lein migrate` | Apply any new migration files to your database |\n| `lein rollback` | Undo the last migration (reverts the last change) |\n| `lein database` | Fill the database with starter data (default users, examples) |\n| `lein convert-migrations mysql` | Convert your SQLite migrations to MySQL format |\n| `lein convert-migrations postgresql` | Convert your SQLite migrations to PostgreSQL format |\n| `lein copy-data mysql` | Copy all your data from SQLite to a MySQL database |\n| `lein copy-data postgresql` | Copy all your data from SQLite to a PostgreSQL database |\n\n### Migrations (Database Version Control)\n\nA **migration** is a SQL file that makes a change to your database (create a table, add a column, etc.). Migration files live in `resources/migrations/` and work in pairs:\n\n```\nresources/migrations/\n  001-users.sqlite.up.sql              ← Creates the users table\n  001-users.sqlite.down.sql            ← Reverses it (drops the table)\n  002-users_view.sqlite.up.sql\n  002-users_view.sqlite.down.sql\n  ...\n```\n\n**Rules:**\n- Each migration needs both an `.up.sql` (apply) and `.down.sql` (undo) file\n- The number prefix (`001`, `002`...) controls the order they run\n- The `.sqlite` part means \"this is for SQLite\" — the framework can convert to MySQL/PostgreSQL format automatically\n\n**Example migration** (`001-users.sqlite.up.sql`):\n\n```sql\nCREATE TABLE IF NOT EXISTS users (\n  id         INTEGER PRIMARY KEY AUTOINCREMENT,\n  firstname  TEXT NOT NULL,\n  lastname   TEXT NOT NULL,\n  username   TEXT NOT NULL UNIQUE,\n  password   TEXT NOT NULL,\n  email      TEXT,\n  level      TEXT DEFAULT 'U',\n  active     TEXT DEFAULT 'T',\n  created_at TEXT DEFAULT (datetime('now'))\n);\n```\n\n**Example rollback** (`001-users.sqlite.down.sql`):\n\n```sql\nDROP TABLE IF EXISTS users;\n```\n\n### Switching to MySQL/PostgreSQL\n\n1. Write migrations as `.sqlite.up.sql` / `.sqlite.down.sql`.\n2. Run `lein convert-migrations mysql` to generate `.mysql.up.sql` / `.mysql.down.sql` files.\n3. Change `:main :sqlite` to `:main :mysql` in `app-config.edn`.\n4. Change `:default :sqlite` to `:default :mysql`.\n5. Update the `:mysql` connection block with your host/credentials.\n6. Run `lein migrate`.\n\nTo copy existing data: `lein copy-data mysql` (uses topological sort for FK dependencies).\n\n---\n\n## Development Commands\n\nThese are the most common `lein` commands you'll use:\n\n| Command | What it does |\n|---|---|\n| `lein with-profile dev run` | Start the dev server (hot-reload enabled — edit files, refresh browser) |\n| `lein run` | Start the production server (no hot-reload) |\n| `lein test` | Run all unit tests |\n| `lein compile` | Check your code for errors and compile it |\n| `lein uberjar` | Build a standalone `.jar` file for deployment |\n| `lein repl` | Start an interactive Clojure REPL (advanced) |\n\n### Deploying to Production\n\n```bash\nlein uberjar                                    # Build the package\njava -jar target/uberjar/my-project-0.1.0-standalone.jar   # Run it\n```\n\n---\n\n## Entity Configuration (The Core of the Framework)\n\nEvery database table you want to manage through the admin UI gets a **configuration file** in `resources/entities/`. These `.edn` files tell the engine:\n\n- What **fields** the table has (name, type, whether required)\n- What **SQL queries** to use for listing and editing records\n- Who has **permission** to view or edit\n- Where it appears in the **menu**\n- What **relationships** it has with other tables (subgrids)\n- Any custom **business logic** (hooks)\n\nThe engine reads these files and auto-generates the admin interface — **no code needed for basic CRUD**.\n\n### Minimal Entity\n\n```clojure\n{:entity     :products\n :title      \"Products\"\n :table      \"products\"\n :connection :default\n :rights     [\"U\" \"A\" \"S\"]\n :menu-category :Inventory\n\n :fields [{:id :id    :type :hidden}\n          {:id :name  :label \"Name\"  :type :text    :required? true}\n          {:id :price :label \"Price\" :type :decimal :min 0 :step 0.01}]\n\n :queries {:list \"SELECT * FROM products ORDER BY name\"\n           :get  \"SELECT * FROM products WHERE id = ?\"}\n\n :actions {:new true :edit true :delete true}}\n```\n\n### Full Entity with All Options (Reference Example)\n\nThis example shows every field type and configuration option available. You don't need to use all of them — start with the [Minimal Entity](#minimal-entity) above and add options as needed.\n\n```clojure\n{:entity     :employees\n :title      \"Employees\"\n :table      \"employees\"\n :connection :default\n :rights     [\"A\" \"S\"]              ; Only Admin and System users\n :mode       :parameter-driven      ; :parameter-driven | :generated | :hybrid\n :menu-category :HR\n :menu-order    10\n :menu-icon     \"bi bi-people\"\n :menu-hidden  false                ; true = hide from main menu (subgrids only)\n\n :fields [{:id :id         :type :hidden}\n\n          ;; TEXT — single-line string input\n          {:id :firstname  :label \"First Name\"  :type :text :placeholder \"First name...\"\n           :required? true :validation :my-proj.hooks.employees/validate-name}\n\n          ;; EMAIL — text input with email type attribute\n          {:id :email      :label \"Email\" :type :email :placeholder \"user@example.com\"}\n\n          ;; PASSWORD — password input (masked). If included in entity, the engine\n          ;; auto-hashes on save using buddy.hashers. Typically excluded from CRUD\n          ;; forms — use the \"Create Temporary Password\" flow instead (see below).\n          {:id :password   :label \"Password\" :type :password}\n\n          ;; NUMBER — integer input (HTML number)\n          {:id :age        :label \"Age\" :type :number :min 18 :max 120}\n\n          ;; DECIMAL — floating point input\n          {:id :salary     :label \"Salary\" :type :decimal :min 0 :step 0.01}\n\n          ;; DATE — date picker (HTML5 input type=date)\n          {:id :hire_date  :label \"Hire Date\" :type :date}\n\n          ;; DATETIME — datetime picker (HTML5 input type=datetime-local)\n          {:id :last_login :label \"Last Login\" :type :datetime}\n\n          ;; TEXTAREA — multiline text input\n          {:id :notes      :label \"Notes\" :type :textarea :placeholder \"Additional notes...\"}\n\n          ;; SELECT — dropdown (single choice). Use :options for static choices,\n          ;; or :query for dynamic options from the database.\n          {:id :department_id :label \"Department\" :type :select\n           :options [{:value \"\" :label \"Select Department\"}\n                     {:value \"1\" :label \"Engineering\"}\n                     {:value \"2\" :label \"Sales\"}\n                     {:value \"3\" :label \"Marketing\"}]}\n\n          ;; FK — foreign key select (dynamic options from referenced entity).\n          ;; Use :fk with the entity keyword. :fk-field can be a keyword or\n          ;; a vector of keywords for composite display (e.g. [:first_name :last_name]).\n          {:id :department_id :label \"Department\" :type :fk\n           :fk :departments              ; Referenced entity keyword\n           :fk-field [:name]             ; Display field(s) from referenced entity\n           :fk-can-create? true          ; Show \"+\" button to create inline via modal\n           :hidden-in-grid? true}        ; Hide FK ID in grid (show display name)\n          ;; Display-only field on grid (must be aliased in :queries :list)\n          {:id :department_nombre :label \"Department\" :grid-only? true}\n\n          ;; FK with composite display name\n          {:id :manager_id :label \"Manager\" :type :fk\n           :fk :employees\n           :fk-field [:first_name :last_name]\n           :fk-can-create? true\n           :hidden-in-grid? true}\n          {:id :employee_nombre :label \"Manager\" :grid-only? true}\n\n          ;; DEPENDENT FK — second dropdown filtered by the parent FK value.\n          ;; :fk-parent is the field name (keyword) in the same entity whose\n          ;; selected value drives the filter.\n          {:id :municipio_id :label \"Municipio\" :type :fk\n           :fk :municipios\n           :fk-field :nombre\n           :fk-parent :estado_id\n           :fk-can-create? true}\n\n          ;; FK WITH FILTER — static filter applied to all FK dropdown queries.\n          ;; A vector of [field value] that adds WHERE field = value to the\n          ;; FK options query.\n          {:id :department_id :label \"Department\" :type :fk\n           :fk :departments\n           :fk-field :name\n           :fk-filter [:active \"T\"]}\n\n          ;; RADIO — single choice (horizontal buttons)\n          {:id :active     :label \"Active\" :type :radio :value \"T\"\n           :options [{:id \"activeT\" :value \"T\" :label \"Active\"}\n                     {:id \"activeF\" :value \"F\" :label \"Inactive\"}]}\n\n          ;; CHECKBOX — single boolean toggle\n          {:id :is_manager :label \"Is Manager\" :type :checkbox}\n\n          ;; FILE — file upload. Automatically saves to :uploads directory.\n          ;; Use hooks for custom file processing (resize, rename, etc.)\n          {:id :avatar     :label \"Avatar\" :type :file}\n\n          ;; COMPUTED — calculated field (not stored, read-only in grid)\n          {:id :full_name  :label \"Full Name\" :type :computed\n           :compute-fn :my-proj.hooks.employees/full-name}\n\n          ;; HIDDEN — not displayed in forms or grids\n          {:id :created_by :label \"Created By\" :type :hidden}]\n\n ;; QUERIES — custom SQL for list and single-record views.\n ;; :list is used for the grid view, :get for the edit form.\n :queries {:list \"SELECT e.*, d.name AS department_name\n                  FROM employees e\n                  LEFT JOIN departments d ON e.department_id = d.id\n                  ORDER BY e.lastname\"\n           :get  \"SELECT e.*, d.name AS department_name\n                  FROM employees e\n                  LEFT JOIN departments d ON e.department_id = d.id\n                  WHERE e.id = ?\"}\n\n ;; ACTIONS — toggle record-level operations\n :actions {:new    true     ; Show \"New Record\" button\n           :edit   true     ; Show \"Edit\" button per row\n           :delete true}    ; Show \"Delete\" button per row (with confirmation)\n\n ;; AUDIT — automatically track who created/modified records and when.\n ;; Requires audit_log table (migration 006-audit_log).\n :audit? true\n\n ;; HOOKS — lifecycle functions called at specific points.\n ;; Each hook receives the params/row map and returns a (possibly modified) map.\n :hooks {:before-load  :my-proj.hooks.employees/before-load   ; before query\n         :after-load   :my-proj.hooks.employees/after-load    ; after query\n         :before-save  :my-proj.hooks.employees/before-save   ; before insert/update\n         :after-save   :my-proj.hooks.employees/after-save    ; after insert/update\n         :before-delete :my-proj.hooks.employees/before-delete ; before delete\n         :after-delete  :my-proj.hooks.employees/after-delete} ; after delete\n\n ;; SUBGRIDS — child entities displayed as tabs on the edit form.\n ;; :foreign-key is the FK column in the child table.\n ;; :relationship-type can be :one-to-one, :one-to-many, or :many-to-many.\n ;; For :many-to-many, also set :through-table, :related-entity, and :related-fk.\n :subgrids [{:entity      :employee_profiles\n             :title       \"Profile\"\n             :foreign-key :employee_id\n             :icon        \"bi bi-person-badge\"\n             :label       \"Profile\"\n             :relationship-type :one-to-one}\n            {:entity      :employee_projects\n             :title       \"Projects\"\n             :foreign-key :employee_id\n             :icon        \"bi bi-briefcase\"\n             :label       \"Projects\"\n             :relationship-type :many-to-many\n             :through-table :employee_projects\n             :related-entity :projects\n             :related-fk :project_id}]}\n```\n\n### Complete Entity Config Spec\n\nEvery entity EDN is validated against this spec:\n\n| Key | Required | Type | Description |\n|---|---|---|---|\n| `:entity` | yes | keyword | Unique entity identifier (e.g., `:products`) |\n| `:title` | yes | string | Display title for UI |\n| `:table` | yes | string | Database table name |\n| `:connection` | no | keyword | Connection key (`:default`, `:mysql`, `:pg`). Defaults to `:default` |\n| `:rights` | no | `[string]` | Allowed user levels. Defaults to `[\"U\" \"A\" \"S\"]` |\n| `:mode` | no | keyword | `:parameter-driven` (default), `:generated`, or `:hybrid` |\n| `:menu-category` | no | keyword | Group in the nav menu |\n| `:menu-order` | no | number | Sort order in the menu |\n| `:menu-icon` | no | string | Bootstrap icon class (e.g., `\"bi bi-people\"`) |\n| `:dropdown-icon` | no | string | Dropdown icon for menu |\n| `:menu-hidden` | no | boolean | Hide from menu (for subgrid-only entities). Uses `:menu-hidden true` (not `:menu-hidden?`) |\n| `:fields` | no | `[field]` | Field definitions (see below) |\n| `:queries` | no | map | `{:list \u003csql\u003e :get \u003csql\u003e}` — custom SQL queries |\n| `:actions` | no | map | `{:new \u003cbool\u003e :edit \u003cbool\u003e :delete \u003cbool\u003e}` |\n| `:audit?` | no | boolean | Enable audit trail |\n| `:hooks` | no | map | `{:before-save \u003cfn-ref\u003e :after-load \u003cfn-ref\u003e ...}` |\n| `:subgrids` | no | `[subgrid]` | Child entity configuration |\n\n### Field Spec\n\nEach field supports:\n\n| Key | Required | Type | Description |\n|---|---|---|---|\n| `:id` | yes | keyword | Field identifier (matches DB column) |\n| `:label` | yes | string | Display label |\n| `:type` | yes | keyword | One of: `:text`, `:email`, `:password`, `:date`, `:datetime`, `:number`, `:decimal`, `:select`, `:radio`, `:checkbox`, `:textarea`, `:file`, `:hidden`, `:computed`, `:fk` |\n| `:required?` | no | boolean | Mark field as required |\n| `:placeholder` | no | string | Placeholder text |\n| `:value` | no | any | Default value |\n| `:options` | no | `[{:value \u003cstr\u003e :label \u003cstr\u003e}]` | Options for select/radio fields |\n| `:validation` | no | keyword/fn | Custom validation function |\n| `:compute-fn` | no | keyword/fn | Computed field function (for `:computed` type) |\n| `:fk` | no | keyword | Referenced entity keyword (used with `:type :fk`). E.g., `:departments` |\n| `:fk-field` | no | keyword or vec | Display column(s) from the FK entity. Single: `:name`, composite: `[:first_name :last_name]` |\n| `:fk-parent` | no | keyword | Parent field keyword for dependent FK selects. E.g., `:estado_id` |\n| `:fk-filter` | no | `[field value]` | Static filter on FK options. E.g., `[:active \"T\"]` adds `WHERE active = ?` |\n| `:fk-can-create?` | no | boolean | Show \"+\" button to create FK record inline via modal |\n| `:hidden-in-grid?` | no | boolean | Hide FK ID in grid (show display name from `:grid-only?` field instead) |\n| `:hidden-in-form?` | no | boolean | Hide in forms (for grid-only fields) |\n| `:grid-only?` | no | boolean | Only show in grid, never in form (used for display aliases from SQL) |\n| `:min` / `:max` | no | number | Min/max for number and decimal fields |\n| `:step` | no | number | Step increment for decimal |\n\n---\n\n## Adding an Entity\n\n### Scaffold from an existing database table\n\n```bash\nlein scaffold products\n```\n\nThis introspects the `products` table in the database and generates:\n- `resources/entities/products.edn` — full entity config with auto-detected field types, FKs, and subgrids\n- `src/\u003cproject\u003e/hooks/products.clj` — lifecycle hook stub\n\nThe scaffold engine auto-detects:\n- **SQL type → field type** (e.g., `VARCHAR` → `:text`, `INT` → `:number`, `DECIMAL` → `:decimal`, `DATE` → `:date`, `TEXT` → `:textarea`)\n- **Convention-based recognition** (column named `email` → `:email`, `password` → `:password`, `imagen`/`image`/`photo` → `:file`, etc.)\n- **Foreign keys** (columns ending in `_id`) → `:select` with `:foreign-key` metadata\n- **Subgrids** (reverse FK lookup in other tables) → auto-populates `:subgrids`\n- **Relationship type** (1:1, 1:N, M:N) via unique index analysis\n- **Junction tables** (composite PK with two FK columns) → many-to-many support\n\n#### What `--force` does\n\n```bash\nlein scaffold products --force\n```\n\nBy default, scaffold **safely updates existing files** — it adds any new fields or subgrids it finds but leaves your custom changes alone. Use `--force` to overwrite everything:\n\n| What | Without `--force` (default — safe) | With `--force` (⚠️ overwrites) |\n|---|---|---|\n| **Entity EDN** (`resources/entities/products.edn`) | Adds new fields/subgrids only. Your custom `:queries`, `:hooks`, `:menu-category`, etc. stay as-is. | **Overwrites** the whole file. All your custom edits are lost. |\n| **Hook file** (`src/\u003cproject\u003e/hooks/products.clj`) | **Skipped** with a warning if the file already exists. Your custom code is untouched. | **Regenerated** from scratch. All your custom hook code is lost. |\n\n**Use `--force` when:**\n- You renamed database columns and the entity config needs to match\n- You deleted and recreated a table with different columns\n- You want a completely fresh start\n\n**Skip `--force` (safe mode) when:**\n- You've added custom SQL queries, hooks, or menu settings you want to keep\n- You only added a new column to the table and want it merged in\n\n### Scaffold all tables at once\n\n```bash\nlein scaffold --all\n```\n\nScaffolds every non-system table. Idempotent — already-scaffolded tables are merged with new subgrids without losing customisations.\n\n```bash\nlein scaffold --all --force    # Overwrite every entity EDN and regenerate all hooks\n```\n\n### Other scaffold options\n\n```bash\nlein scaffold products --no-hooks          # Skip hook stub generation\nlein scaffold products --rights [A S]      # Set user rights\nlein scaffold products --title \"Products\"  # Custom display title\nlein scaffold employees --conn :pg         # Use a different database connection\nlein scaffold --all --exclude sessions,schema_migrations   # Skip certain tables\n```\n\n### Create manually (step by step)\n\n**Step 1: Write a migration file** — this creates your database table. Create two files in `resources/migrations/`:\n\n```sql\n-- 001-products.sqlite.up.sql    ← Creates the table\nCREATE TABLE IF NOT EXISTS products (\n  id      INTEGER PRIMARY KEY AUTOINCREMENT,\n  name    TEXT NOT NULL,\n  price   REAL DEFAULT 0.0,\n  active  TEXT DEFAULT 'T'\n);\n```\n\n```sql\n-- 001-products.sqlite.down.sql  ← Reverses the creation (for rollback)\nDROP TABLE IF EXISTS products;\n```\n\n\u003e 💡 **Naming rule:** Each migration needs `.up.sql` (to create) and `.down.sql` (to undo). The number (`001`, `002`...) determines the order they run.\n\n**Step 2: Apply the migration** to your database:\n\n```bash\nlein migrate\n```\n\n**Step 3: Create the entity config file** at `resources/entities/products.edn`. Start with the [Minimal Entity](#minimal-entity) example above.\n\n**Step 4: Refresh your browser** — the new entity appears in the admin menu with full Create/Read/Update/Delete.\n\n---\n\n## Included Example Entities\n\nThese are defined by the migrations in `resources/migrations/` and pre-configured as entity EDNs in `resources/entities/`.\n\n### Users (`001-users`, `resources/entities/users.edn`)\n- Columns: `id`, `lastname`, `firstname`, `username`, `password`, `dob`, `cell`, `phone`, `fax`, `email`, `level`, `active`, `imagen`, `last_login`\n- Three seed users: `user@example.com`/`user` (level `U`), `admin@example.com`/`admin` (level `A`), `system@example.com`/`system` (level `S`)\n- Access restricted to Admins and System (`:rights [\"A\" \"S\"]`). Password field excluded from CRUD forms — use the temp-password flow instead.\n- Lifecycle hooks handle file upload if the `:imagen` field is uncommented.\n\n### Users View (`002-users_view`)\n- Creates a database view (`users_view`) with formatted fields for reports.\n\n### Contactos (`003-contactos`, `resources/entities/contactos.edn`)\n- Columns: `id`, `name`, `email`, `phone`, `imagen`\n- File upload (`:type :file` for `:imagen`) with `before-save` / `after-load` hooks for image processing.\n- Two subgrids: Cars and Siblings (FK column `contacto_id`).\n\n### Siblings (`004-siblings`, `resources/entities/siblings.edn`)\n- Columns: `id`, `name`, `age`, `imagen`, `contacto_id` (FK → contactos)\n- Subgrid child of Contactos (`:menu-hidden true` — no main menu entry; visible only as a tab in the Contactos form).\n\n### Cars (`005-cars`, `resources/entities/cars.edn`)\n- Columns: `id`, `company`, `model`, `year`, `imagen`, `contacto_id` (FK → contactos)\n- Subgrid child of Contactos (`:menu-hidden true`).\n\n### Audit Log (`006-audit_log`)\n- Tracks record changes when `:audit? true` is set on an entity config.\n\n### Relationship Examples (`007-relationship_examples`)\nEight tables demonstrating real-world relational patterns — great to study if you're new to database relationships:\n\n- **organizations** — Top-level table. Each organization has multiple departments (subgrid).\n- **departments** — Has a FK `organization_id` linking to organizations. Each department has multiple employees.\n- **employees** — Linked to a department. Also has a `manager_id` linking to another employee (your boss is also in the same table!). Has three subgrids: one-to-one profile, and two many-to-many relationships (projects and skills).\n- **employee_profiles** — One-to-one with employees (each employee has exactly one profile).\n- **projects** — Linked to employees through a junction table (many-to-many: an employee can work on many projects, a project can have many employees).\n- **employee_projects** — A **junction table** linking employees to projects. Also stores extra info: `role`, `hours_per_week`.\n- **skills** — Linked to employees through a junction table (many-to-many).\n- **employee_skills** — A junction table linking employees to skills, with a `proficiency` column.\n\nAll use `:mode :parameter-driven`, `:connection :default`, and `:rights [\"U\" \"A\" \"S\"]`.\n\n---\n\n## Custom Page Handlers (MVC)\n\nThe parameter-driven engine handles basic CRUD automatically, but when you need a custom page (like a dashboard, a phone-order screen, or a kitchen display board), you build a **handler**. Each handler has three files that work together:\n\n| File | What it does | Analogy |\n|---|---|---|\n| **Controller** (`controller.clj`) | Receives the web request, calls the model, sends the result to the view | Like a restaurant host — seats customers (routes requests) |\n| **Model** (`model.clj`) | Fetches data from the database | Like the kitchen — prepares the food (data) |\n| **View** (`view.clj`) | Renders HTML for the browser | Like the plating chef — makes it look good |\n\nThis is called the **MVC pattern** (Model-View-Controller). Don't worry if that sounds fancy — the framework keeps it simple.\n\n### Generating a Handler Skeleton\n\n```bash\nlein gen-handler reports\n```\n\nThis creates:\n```\nsrc/\u003cproject\u003e/handlers/reports/\n  controller.clj    — HTTP request handler\n  model.clj          — Data access (queries)\n  view.clj           — HTML rendering via Hiccup\n```\n\nAnd automatically adds the require to `src/\u003cproject\u003e/routes/proutes.clj`.\n\nTo remove:\n\n```bash\nlein gen-handler reports remove\n```\n\n### Handler Structure\n\n#### Controller (`controller.clj`)\n\nThe controller receives the incoming web request, asks the model for data, passes it to the view to generate HTML, then wraps the page in the app's layout (nav bar, footer, theme).\n\n```clojure\n(ns my-project.handlers.reports.controller\n  (:require\n   [my-project.handlers.reports.model :as model]\n   [my-project.handlers.reports.view :as view]\n   [my-project.layout :refer [application]]\n   [my-project.models.util :refer [get-session-id]]))\n\n(defn contactos\n  [request]\n  (let [title \"Reporte de contactos\"\n        ok (get-session-id request)   ; 0 = not logged in, \u003e0 = user ID\n        js nil                        ; optional JavaScript to inject\n        rows (model/get-contactos)\n        content (view/contactos request title rows)]\n    (application request title ok js content)))\n```\n\nThe `application` function from `layout.clj` wraps content in the full HTML page (nav bar, footer, theme). Its signature is:\n\n```clojure\n(application request title ok js content)\n```\n\n- `request` — the incoming web request (contains browser info, form data, etc.)\n- `title` — page title (shown in the browser tab)\n- `ok` — user ID (0 = not logged in, \u003e0 = logged in)\n- `js` — optional extra JavaScript to add to the page\n- `content` — the HTML for the page body (generated by the view)\n\n#### Model (`model.clj`)\n\nThe model contains SQL queries and data access functions using `Query` from `models.crud`:\n\n```clojure\n(ns my-project.handlers.reports.model\n  (:require\n   [my-project.models.crud :refer [Query]]))\n\n(def ^:private contactos-sql\n  \"SELECT con.*,\n          (SELECT group_concat(name, ', ') FROM siblings WHERE contacto_id = con.id) AS siblings,\n          (SELECT group_concat(company || ' ' || model || ' ' || year, ', ')\n             FROM cars WHERE contacto_id = con.id) AS cars\n   FROM contactos con\n   ORDER BY con.name\")\n\n(def ^:private users-sql\n  \"SELECT * FROM users_view\")\n\n(defn get-contactos []\n  (Query contactos-sql))\n\n(defn get-users []\n  (Query users-sql))\n```\n\n#### View (`view.clj`)\n\nThe view renders HTML using **Hiccup** — a Clojure way of writing HTML using vectors (square brackets `[...]`). For example, `[:h1 \"Hello\"]` becomes `\u003ch1\u003eHello\u003c/h1\u003e`. For report tables, use the ready-made `build-report` function from `models.grid` — it handles sorting, searching, and export buttons for you:\n\n```clojure\n(ns my-project.handlers.reports.view\n  (:require\n   [my-project.models.grid :refer [build-report]]))\n\n(def ^:private contactos-fields\n  (array-map\n   :name           \"Nombre\"\n   :phone          \"Telefono\"\n   :email          \"Email\"\n   :estado_nombre  \"Estado\"\n   :municipio_nombre \"Municipio\"\n   :colonia_nombre \"Colonia\"\n   :siblings       \"Hermanos\"\n   :cars           \"Automobiles\"))\n\n(defn contactos\n  [request title rows]\n  (build-report request title rows \"contactos-report\" contactos-fields))\n```\n\nThe `build-report` function:\n- Renders a read-only table with sortable columns\n- Includes search/filter bar\n- Provides CSV export (`?export=csv`), PDF export (`?export=pdf`), and Print buttons\n- Adds `@media print` CSS for clean print output\n- Auto-detects `?search=`, `?sort-by=`, `?sort-order=` query parameters\n\nThe full `build-report` function accepts a few advanced options as well:\n\n```clojure\n(build-report request title rows table-id fields \u0026 [page-info current-params])\n```\n\nDon't worry about the extra parameters — for simple reports, the 5-parameter version shown above is all you need.\n\n### Generating Custom Route Registrations\n\nWhen you use `lein gen-handler \u003cname\u003e`, the tool automatically adds a require and route to `proutes.clj`. You must manually add the route handler call. Edit `src/\u003cproject\u003e/routes/proutes.clj`:\n\n```clojure\n(ns my-project.routes.proutes\n  (:require\n   [compojure.core :refer [defroutes GET]]\n   [my-project.handlers.dashboard.controller :as dashboard]\n   [my-project.handlers.reports.controller :as reports]))  ;; ← auto-added\n\n(defroutes proutes\n  (GET \"/dashboard\" req (dashboard/main req))\n  (GET \"/reports/contactos\" req (reports/contactos req))   ;; ← add this\n  (GET \"/reports/users\" req (reports/users req)))           ;; ← add this\n```\n\n---\n\n## Manual Routes (Connecting URLs to Pages)\n\nA **route** tells the app: \"When someone visits this URL, run this code.\" Routes live in two files under `src/\u003cproject\u003e/routes/`:\n\n- **`routes.clj`** — pages that anyone can visit (login page, etc.)\n- **`proutes.clj`** — pages that require a login (protected)\n\n### Public Routes (`routes.clj`) — No Login Needed\n\n```clojure\n(ns my-project.routes.routes\n  (:require\n   [compojure.core :refer [defroutes GET POST]]\n   [my-project.handlers.home.controller :as home-controller]))\n\n(defroutes open-routes\n  (GET \"/\" params [] (home-controller/main params))\n  (GET \"/home/login\" params [] (home-controller/login params))\n  (POST \"/home/login\" params [] (home-controller/login-user params))\n  (GET \"/home/logoff\" params [] (home-controller/logoff-user params)))\n\n(defroutes password-routes\n  (GET \"/change/password\" params [] (home-controller/change-password params))\n  (POST \"/change/password\" params [] (home-controller/process-password params))\n  (GET \"/home/temp-password\" params [] (home-controller/temp-password params))\n  (POST \"/home/temp-password\" params [] (home-controller/process-temp-password params)))\n```\n\nAdd new public routes here. They are composed in `core.clj` with no auth middleware.\n\n### Protected Routes (`proutes.clj`)\n\nAuthentication required. The `wrap-login` middleware (applied in `core.clj`) redirects unauthenticated users to the login page:\n\n```clojure\n(ns my-project.routes.proutes\n  (:require\n   [compojure.core :refer [defroutes GET]]\n   [my-project.handlers.dashboard.controller :as dashboard]\n   [my-project.handlers.reports.controller :as reports]))\n\n(defroutes proutes\n  (GET \"/dashboard\" req (dashboard/main req))\n  (GET \"/reports/contactos\" req (reports/contactos req))\n  (GET \"/reports/users\" req (reports/users req)))\n```\n\n### Dynamic Engine Routes\n\nAll parameter-driven CRUD routes are auto-generated by the engine in `engine/router.clj`:\n\n| Route | Description |\n|---|---|\n| `GET /admin/:entity` | Grid list with search, sort, pagination, TabGrid |\n| `GET /admin/:entity/add-form` | New record form |\n| `GET /admin/:entity/add-form/:parent_id` | New record (subgrid context) |\n| `GET /admin/:entity/edit-form/:id` | Edit record form |\n| `POST /admin/:entity/save` | Create or update record |\n| `POST /admin/:entity/delete/:id` | Delete record |\n| `GET /admin/:entity/delete/:id` | Delete (GET, backward compat) |\n| `GET /admin/:entity/subgrid` | Subgrid AJAX |\n| `GET /admin/:entity/:id` | Grid view scrolled to record |\n| `GET /dashboard/:entity` | Entity dashboard (supports `?export=csv` and `?export=pdf`) |\n| `GET /admin/reload-config` | Hot-reload all entity configs |\n\nEngine routes are composed in `core.clj` with login middleware:\n\n```clojure\n(wrap-login (wrap-routes (engine/get-routes)))\n```\n\n---\n\n## Menu Customization\n\nThe navigation menu is assembled by `src/\u003cproject\u003e/menu.clj`. It automatically creates dropdown groups from your entity configs and lets you add custom links. The three sections below show how to add your own menu items.\n\n### Custom Nav Links\n\nAdd standalone nav links (no dropdown) for pages without a backing entity — custom handlers, dashboards, external URLs:\n\n```clojure\n;; src/\u003cproject\u003e/menu.clj\n(def custom-nav-links\n  \"Custom navigation links (non-dropdown, not entity-based)\"\n  [[\"/\"          \"HOME\"      \"bi bi-house\"        nil 0]\n   [\"/dashboard\" \"DASHBOARD\" \"bi bi-speedometer2\" \"U\" 10]\n   [\"/pedido\"    \"PEDIDO\"    \"bi bi-telephone\"    \"U\" 20]\n   [\"/despacho\"  \"DESPACHO\"  \"bi bi-truck\"        \"U\" 30]])\n```\n\nEach entry follows `[\"/path\" \"Label\" \"icon-class\" \"rights\" order]`:\n\n| Element | Description |\n|---|---|\n| `\"/path\"` | URL path |\n| `\"Label\"` | Display text |\n| `\"icon-class\"` (optional) | Bootstrap icon class, e.g. `\"bi bi-house\"`. Can appear in 3rd or 4th position — auto-detected by the `bi ` prefix |\n| `\"rights\"` (optional) | User level: `nil` = everyone, `\"U\"` = Users+, `\"A\"` = Admins only |\n| `order` (optional) | Sort order (lower = first). Defaults to `0` |\n\nSupported shorthand forms: `[\"/\" \"HOME\"]`, `[\"/\" \"HOME\" \"U\"]`, `[\"/\" \"HOME\" \"U\" 10]`, `[\"/\" \"HOME\" \"bi bi-house\"]`, `[\"/\" \"HOME\" \"bi bi-house\" 10]`.\n\n### Custom Dropdown Menus\n\nCreate entirely new dropdown menu groups (not tied to any entity category):\n\n```clojure\n;; src/\u003cproject\u003e/menu.clj\n(def custom-dropdowns\n  \"Custom dropdown menus (not entity-based)\"\n  {:Reports\n   {:id      \"navdrop-reports\"\n    :data-id \"Reports\"\n    :label   \"Reports\"\n    :order   40\n    :icon    \"bi bi-printer\"\n    :items   [[\"/reports/contactos\" \"Contacts\"  \"U\" 10 \"bi bi-people\"]\n              [\"/reports/users\"     \"Users\"     \"A\" 50 \"bi bi-people\"]]}})\n```\n\n| Key | Description |\n|---|---|\n| `:label` | Dropdown display text |\n| `:order` | Position among all dropdowns (lower = first) |\n| `:icon` | Bootstrap icon for the dropdown toggle |\n| `:items` | Vector of nav-link entries (same format as `custom-nav-links`) |\n\nEach item in `:items` follows the same `[\"/path\" \"Label\" \"rights\" order \"icon\"]` format as nav links.\n\n### Adding Items to Existing Dropdowns\n\nAppend extra items to auto-generated entity dropdowns (or custom ones) without modifying the entity EDN:\n\n```clojure\n;; src/\u003cproject\u003e/menu.clj\n(def custom-dropdown-items\n  \"Extra items to append to existing dropdowns (auto-generated or custom).\n   Maps a category keyword to items in [href label rights order icon] format.\"\n  {:Users    [[\"/home/temp-password\" \"Temp Password\" \"A\" 10 \"bi bi-file-text\"]]\n   :Reports  [[\"/reports/orders\" \"Orders\" \"U\" 60 \"bi bi-receipt\"]]})\n```\n\nThe category keyword matches the `:menu-category` value from entity EDN files (`:Users`, `:Reports`, `:Catalogos`, etc.). Items are merged into the existing dropdown and sorted by `:order`.\n\n### Entity-Level Menu Config\n\nEntity EDN fields that affect menu placement (already documented in the [Field Spec](#field-spec)):\n\n| Key | Description |\n|---|---|\n| `:menu-category` | Dropdown group (keyword). Entities with the same category appear in the same dropdown |\n| `:menu-order` | Sort order within the dropdown |\n| `:menu-icon` | Bootstrap icon for the nav link |\n| `:dropdown-icon` | Icon override for the dropdown toggle when this entity defines the category |\n| `:menu-hidden` | `true` = hide from menu (for subgrid-only entities) |\n\n### Menu Refresh\n\nEntity menus auto-refresh with config hot-reload (every 2s in dev). For custom nav links and dropdowns, a dev server restart is required, or call from the REPL:\n\n```clojure\n(require '[my-project.engine.menu :as menu])\n(menu/refresh-menu!)\n```\u003c｜end▁of▁thinking｜\u003eNow verify the TOC also needs a link. Let me update it:\n\n\u003c｜｜DSML｜｜tool_calls\u003e\n\u003c｜｜DSML｜｜invoke name=\"read\"\u003e\n\u003c｜｜DSML｜｜parameter name=\"offset\" string=\"false\"\u003e18\n\n### Controller\n\n`src/\u003cproject\u003e/handlers/dashboard/controller.clj`:\n\n```clojure\n(ns my-project.handlers.dashboard.controller\n  (:require\n   [my-project.handlers.dashboard.model :as model]\n   [my-project.handlers.dashboard.view :as view]\n   [my-project.layout :refer [application]]\n   [my-project.models.util :refer [get-session-id]]))\n\n(defn main\n  [request]\n  (let [title \"DASHBOARD\"\n        ok (get-session-id request)\n        js nil\n        stats (model/get-stats)\n        content (view/main title stats)]\n    (application request title ok js content)))\n```\n\n### Model\n\n`src/\u003cproject\u003e/handlers/dashboard/model.clj` uses `sqlite_master` to discover tables and runs parallel count queries with `pmap`:\n\n```clojure\n(ns my-project.handlers.dashboard.model\n  (:require\n   [my-project.models.crud :refer [Query]]))\n\n(def ^:private tables-sql\n  \"SELECT name FROM sqlite_master\n   WHERE type = 'table' AND name \u003c\u003e 'sqlite_sequence'\")\n\n(def ^:private tables\n  (-\u003e\u003e (Query tables-sql)\n       (map :name)))\n\n(defn- tt [table]\n  (let [sql (str \"SELECT COUNT(*) AS count FROM \" table)\n        k (keyword table)\n        v (-\u003e\u003e (Query sql) first :count)]\n    [k v]))\n\n(defn get-stats []\n  (into (sorted-map) (pmap tt tables)))\n```\n\nFor MySQL/PostgreSQL, replace `sqlite_master` with `information_schema.tables`.\n\n### View\n\n`src/\u003cproject\u003e/handlers/dashboard/view.clj` renders a card for each table:\n\n```clojure\n(ns my-project.handlers.dashboard.view)\n\n(defn- card [title count]\n  [:div.col-12.col-sm-6.col-md-4.mb-2\n   [:div.card\n    [:div.card-body\n     [:h5.card-title.text-primary title]\n     [:p.card-text.fw-bolder count]]]])\n\n(defn main [title stats]\n  [:div.container.text-center.text-capitalize.bg-primary.w-50\n   [:h1 title]\n   [:div.row\n    (map (fn [[k v]] (card (name k) v)) stats)]])\n```\n\n### Route Registration\n\nAdd the route in `src/\u003cproject\u003e/routes/proutes.clj`:\n\n```clojure\n(GET \"/dashboard\" req (dashboard/main req))\n```\n\n### Add to Menu\n\nThe dashboard is not an entity-based page, so it needs a manual nav link in `src/\u003cproject\u003e/menu.clj`:\n\n```clojure\n(def custom-nav-links\n  [[\"/\"          \"HOME\"      \"bi bi-house\"        nil 0]\n   [\"/dashboard\" \"DASHBOARD\" \"bi bi-speedometer2\" \"U\" 10]])\n```\n\nSee [Menu Customization](#menu-customization) for all menu configuration options.\n\n### Entity-Specific Dashboards\n\nThe engine provides entity dashboards automatically at `GET /dashboard/:entity`. For example, `GET /dashboard/users` shows a grid of all users with search, sort, export (`?export=csv`, `?export=pdf`), and print. No code needed.\n\n---\n\n## Creating a Report\n\nReports are custom MVC handlers that display read-only data with sorting, searching, CSV export, PDF export, and print support. They use the shared `build-report` function from `models.grid`.\n\n### Step 1: Generate the handler skeleton\n\n```bash\nlein gen-handler reports\n```\n\nThis creates `src/\u003cproject\u003e/handlers/reports/` with `controller.clj`, `model.clj`, `view.clj`.\n\n### Step 2: Implement the Model\n\nWrite SQL queries that join related tables. The model returns a seq of maps:\n\n```clojure\n(ns my-project.handlers.reports.model\n  (:require\n   [my-project.models.crud :refer [Query]]))\n\n(def contactos-sql\n  \"SELECT con.*,\n          (SELECT GROUP_CONCAT(name, ', ') FROM siblings WHERE contacto_id = con.id) AS siblings,\n          (SELECT GROUP_CONCAT(company || ' ' || model || ' ' || year, ', ')\n             FROM cars WHERE contacto_id = con.id) AS cars,\n          est.nombre AS estado_nombre,\n          mun.nombre AS municipio_nombre,\n          col.nombre AS colonia_nombre\n   FROM contactos con\n   LEFT JOIN estados est ON con.estado_id = est.id\n   LEFT JOIN municipios mun ON con.municipio_id = mun.id\n   LEFT JOIN colonias col ON con.colonia_id = col.id\n   ORDER BY con.name\")\n\n(def users-sql\n  \"SELECT * FROM users_view\")\n\n(defn get-contactos [] (Query contactos-sql))\n(defn get-users      [] (Query users-sql))\n```\n\n### Step 3: Implement the View\n\nDefine the column map (keyword → label) and call `build-report`:\n\n```clojure\n(ns my-project.handlers.reports.view\n  (:require\n   [my-project.models.grid :refer [build-report]]))\n\n(def contactos-fields\n  (array-map\n   :name            \"Nombre\"\n   :phone           \"Telefono\"\n   :email           \"Email\"\n   :estado_nombre   \"Estado\"\n   :municipio_nombre \"Municipio\"\n   :colonia_nombre  \"Colonia\"\n   :siblings        \"Hermanos\"\n   :cars            \"Automobiles\"))\n\n(def users-fields\n  (array-map\n   :username         \"Usuario\"\n   :firstname        \"Nombre\"\n   :lastname         \"Apellido\"\n   :dob_formatted    \"Fecha de Nacimiento\"\n   :phone            \"Telefono\"\n   :cell             \"Celular\"\n   :level_formatted  \"Nivel\"\n   :active_formatted \"Status\"))\n\n(defn contactos [request title rows]\n  (build-report request title rows \"contactos-report\" contactos-fields))\n\n(defn users [request title rows]\n  (build-report request title rows \"users-report\" users-fields))\n```\n\n\u003e 💡 **Note:** `array-map` keeps columns in the order you write them. If you used a regular map (`{}`), Clojure might reorder them alphabetically instead.\n\n### Step 4: Implement the Controller\n\n```clojure\n(ns my-project.handlers.reports.controller\n  (:require\n   [my-project.handlers.reports.model :as model]\n   [my-project.handlers.reports.view :as view]\n   [my-project.layout :refer [application]]\n   [my-project.models.util :refer [get-session-id]]))\n\n(defn contactos [request]\n  (let [title \"Reporte de contactos\"\n        ok (get-session-id request)\n        js nil\n        rows (model/get-contactos)\n        content (view/contactos request title rows)]\n    (application request title ok js content)))\n\n(defn users [request]\n  (let [title \"Reporte de usuarios\"\n        ok (get-session-id request)\n        js nil\n        rows (model/get-users)\n        content (view/users request title rows)]\n    (application request title ok js content)))\n```\n\n### Step 5: Register the Routes\n\nEdit `src/\u003cproject\u003e/routes/proutes.clj`:\n\n```clojure\n(ns my-project.routes.proutes\n  (:require\n   [compojure.core :refer [defroutes GET]]\n   [my-project.handlers.dashboard.controller :as dashboard]\n   [my-project.handlers.reports.controller :as reports]))\n\n(defroutes proutes\n  (GET \"/dashboard\" req (dashboard/main req))\n  (GET \"/reports/contactos\" req (reports/contactos req))\n  (GET \"/reports/users\" req (reports/users req)))\n```\n\n### Step 6: Add to Menu\n\nReports are custom handlers (no entity EDN), so add a nav link or dropdown item in `src/\u003cproject\u003e/menu.clj`. For standalone nav links:\n\n```clojure\n(def custom-nav-links\n  [[\"/\"                  \"HOME\"      \"bi bi-house\"        nil 0]\n   [\"/reports/contactos\" \"Contacts\"  \"bi bi-people\"       \"U\" 10]\n   [\"/reports/users\"     \"Users\"     \"bi bi-people\"       \"A\" 20]])\n```\n\nOr add them to an existing or custom dropdown — see [Menu Customization](#menu-customization) for all options.\n\n### What the Report Provides\n\nOnce registered, the report at `/reports/contactos` automatically includes:\n- **Sortable columns** — click any column header to sort ascending, click again for descending\n- **Search/filter** — type in the search box to filter rows (adds `?search=...` to the URL automatically)\n- **CSV export** — click \"Excel\" button, or add `?export=csv` to the URL to download a spreadsheet\n- **PDF export** — click \"PDF\" button, or add `?export=pdf` to the URL\n- **Print** — click \"Print\" button (hides nav bar and buttons for a clean printout)\n- **Bookmarkable URLs** — the search term, sort column, and sort order are all saved in the URL, so you can bookmark or share specific filtered views\n\n---\n\n## Creating a User and Assigning a Temporary Password\n\nThis section covers the admin workflow for creating new user accounts and giving them a temporary password to log in with. The system can email the password automatically if you've set up email (see [Configuring Email](#configuring-email)).\n\n### 1. Create the User Record\n\nAs an Admin or System user, log in and navigate to `/admin/users` → click \"New\" to create a user record. The Users entity form includes:\n\n- **Username** — login identifier (typically email)\n- **Level** — `U` (User), `A` (Admin), `S` (System)\n- **Active** — must be set to `Active` for the user to log in\n- **Email** — optional, used for email delivery of temporary password\n\nThe password field is excluded from the user form for security. New users do not yet have a password.\n\n### 2. Generate a Temporary Password\n\nNavigate to `GET /home/temp-password` (or click the \"Temp Password\" link in the nav if you add one). This page is only accessible to Admin (`A`) and System (`S`) users.\n\n1. **Select the user** from the dropdown\n2. Click **\"Create Temporary Password\"**\n3. The system:\n   - Generates a cryptographically secure 12-character random password (uppercase, lowercase, digits, special chars)\n   - Hashes it with `buddy.hashers/derive` and updates the user's password in the database\n   - Attempts to email the temporary password to the user's email address\n\n### 3. Result Handling\n\nAfter submission, the page shows one of the following:\n\n- **\"Temporary password created. Email sent to user@example.com.\"** — SMTP is configured, the password was emailed successfully.\n- **\"Temporary password created. Email not sent because SMTP is not configured.\"** — Email settings in `app-config.edn` are still set to `\"change_me\"`. The generated password is displayed **on screen** in a `\u003cpre\u003e\u003ccode\u003e` block. Copy it and share it securely with the user.\n- **\"Temporary password created. Email not sent because user has no email address.\"** — The user record has a blank email field. The password is displayed on screen.\n- **\"Temporary password created. Email failed: \u003cerror\u003e\"** — SMTP is configured but sending failed. The password is displayed on screen.\n\n### 4. User Logs In\n\nThe user logs in at `/home/login` with their username and the temporary password. The system then requires them to change their password — they are redirected to `/change/password`.\n\nOn the password change page:\n- Regular users (`U`) see their username as a readonly field\n- Admins (`A`) and System (`S`) can enter any username\n- The user must enter the new password twice (must match)\n- On success, the session is cleared and the user is redirected to login with the new password\n\n### Configuring Email\n\nTo enable automatic email delivery of temporary passwords, update `resources/config/app-config.edn`:\n\n```clojure\n:email-host   \"smtp.gmail.com\"      ; Your SMTP server\n:email-port   465                    ; 465 for SSL, 587 for TLS\n:email-user   \"admin@example.com\"   ; Sender email address\n:email-passwd \"your-app-password\"   ; SMTP password or app password\n:email-ssl    true                  ; true for port 465, false for port 587\n```\n\nThe system validates that all four fields are non-blank and not set to `\"change_me\"` before attempting to send. If email is not configured, the password is shown on screen for manual delivery.\n\n---\n\n## Lifecycle Hooks (Adding Custom Logic)\n\nHooks let you run your own code at specific points when a record is saved, loaded, or deleted. They're defined in the entity EDN file as references to functions in `src/\u003cproject\u003e/hooks/\u003centity\u003e.clj`:\n\n```clojure\n;; resources/entities/products.edn\n{:hooks {:before-save :my-project.hooks.products/before-save\n         :after-load  :my-project.hooks.products/after-load}}\n```\n\nEach hook receives a map and must return a (possibly modified) map.\n\n### Before-Save\n\nRuns just before a record is saved (new or updated). Use this to validate data, process file uploads, auto-fill fields, or stop the save if something's wrong:\n\n```clojure\n(ns my-project.hooks.products)\n\n(defn before-save [params]\n  ;; Process file upload — save file, set column in DB\n  (if-let [file-data (:imagen params)]\n    (if (and (map? file-data) (:tempfile file-data))\n      (-\u003e params\n          (assoc :file file-data :file-column :imagen)\n          (dissoc :imagen))\n      params)\n    params))\n```\n\n### After-Load\n\nRuns after a record is loaded from the database. Use this to format data for display — like converting date formats, computing full names, or adding extra info:\n\n```clojure\n(defn after-load [row]\n  (if (:dob row)\n    (update row :dob #(some-\u003e % (.toString \"yyyy-MM-dd\")))\n    row))\n```\n\n### Before-Delete / After-Delete\n\nRuns before/after a record is deleted. Use `before-delete` to check if it's safe to delete (e.g., prevent deleting a customer who has orders). Throw an error to cancel the delete.\n\n### All Available Hook Points (Cheat Sheet)\n\n| Hook | When it runs | What you can do |\n|---|---|---|\n| `:before-load` | Before loading a record for editing | Add extra filtering or permissions |\n| `:after-load` | After loading a record | Format dates, add computed fields |\n| `:before-save` | Before saving (new or update) | Validate, process uploads, set defaults |\n| `:after-save` | After saving | Send notifications, log activity |\n| `:before-delete` | Before deleting | Check if deletion is safe, abort if needed |\n| `:after-delete` | After deleting | Cleanup related files, notify users |\n\n---\n\n## Audit Trail (Who Did What and When)\n\nThe framework can automatically log every insert, update, and delete to an `audit_log` table, and stamp every record with who created/modified it and when. Enable it per entity by adding `:audit? true` to the entity EDN file.\n\n### Required Fields on Entity Tables\n\nWhen audit is enabled, the framework injects four fields directly into your entity's table on every save:\n\n| Field | Type | When set |\n|---|---|---|\n| `created_by` | INTEGER (user ID) | On first insert |\n| `created_at` | TEXT (ISO-8601) | On first insert |\n| `modified_by` | INTEGER (user ID) | On every insert and update |\n| `modified_at` | TEXT (ISO-8601) | On every insert and update |\n\nYour entity's migration must include these columns:\n\n```sql\nCREATE TABLE products (\n  id          INTEGER PRIMARY KEY AUTOINCREMENT,\n  name        TEXT NOT NULL,\n  price       REAL,\n  created_by  INTEGER,\n  created_at  TEXT,\n  modified_by INTEGER,\n  modified_at TEXT\n);\n```\n\nAlternatively, the framework auto-detects these columns — if they exist in the table, the stamps are applied even without `:audit? true` in the EDN.\n\n### The `audit_log` Table\n\nThe audit log itself stores a separate history of every operation:\n\n```sql\nCREATE TABLE IF NOT EXISTS audit_log (\n  id        INTEGER PRIMARY KEY AUTOINCREMENT,\n  entity    TEXT    NOT NULL,\n  operation TEXT    NOT NULL,\n  data      TEXT,\n  user_id   INTEGER,\n  timestamp TEXT\n);\n```\n\nIf you created your project with `lein setup`, migration `006-audit_log` already creates this table. Run it:\n\n```bash\nlein migrate\n```\n\nIf you added audit support later, create a new migration:\n\n```bash\nlein scaffold-migration add-audit-log\n```\n\nThen put the `CREATE TABLE` SQL above into the up file and `DROP TABLE IF EXISTS audit_log` into the down file.\n\n### What Gets Recorded\n\n| Column | Purpose |\n|---|---|\n| `entity` | Entity keyword (e.g. `\"products\"`) |\n| `operation` | `\"create\"`, `\"update\"`, or `\"delete\"` |\n| `data` | Full record data (serialized as string via `pr-str`) |\n| `user_id` | Logged-in user who performed the action (`null` for anonymous) |\n| `timestamp` | ISO-8601 instant when the action occurred |\n\n### How to Use\n\n1. Add the four audit fields (`created_by`, `created_at`, `modified_by`, `modified_at`) to your entity's migration\n2. Ensure the `audit_log` table exists in your database\n3. Add `:audit? true` to your entity EDN:\n\n```clojure\n{:entity :products\n :audit? true\n :fields [...]\n ...}\n```\n\n4. Restart or refresh — every create, update, and delete on `products` is now logged and stamped\n\n### Querying the Audit Log\n\nThe audit log is a plain table — query it directly with SQL:\n\n```sql\n-- See all changes for a specific entity\nSELECT * FROM audit_log WHERE entity = 'products' ORDER BY id DESC;\n\n-- See who changed what\nSELECT al.*, u.email\nFROM audit_log al\nJOIN users u ON al.user_id = u.id\nWHERE al.entity = 'products'\nORDER BY al.id DESC;\n```\n\n---\n\n## TabGrid Interface\n\nEntities with `:subgrids` automatically use the TabGrid interface, which displays:\n- A **navigator panel** on the left with parent records\n- **Tabs** in the main area showing child subgrids\n- **Inline CRUD** for subgrid records\n- **Many-to-many** management for junction tables\n\nThis replaces the default flat grid view when subgrids are configured.\n\n### Subgrid Config Options\n\nEach entry in `:subgrids` defines one child tab:\n\n```clojure\n:subgrids [{:entity      :cars              ; Child table (must have its own .edn file)\n            :title       \"Cars\"            ; Text shown on the tab\n            :foreign-key :contacto_id      ; Column in child table that links back to parent\n            :icon        \"bi bi-car\"       ; Bootstrap icon for the tab\n            :label       \"Cars\"            ; Label in the dropdown\n            :relationship-type :one-to-many}]  ; See table below\n```\n\n---\n\n## Internationalization (i18n) — Multiple Languages\n\nWant your app in English, Spanish, or more? Translation files live in `resources/i18n/` as simple key-value maps:\n\n```clojure\n;; resources/i18n/es.edn\n{:auth/login \"Iniciar Sesion\"\n :auth/welcome \"Bienvenido\"\n :common/search \"Buscar\"\n :common/new \"Nuevo\"}\n```\n\nThe default locale is set in `app-config.edn` at `:app :default-locale`. Users can switch languages at `/set-language/:locale` or via the POST route `/set-language`.\n\nTo translate a string in code:\n\n```clojure\n(require '[my-project.i18n.core :as i18n])\n(i18n/tr request :auth/login)\n```\n\n---\n\n## Entity Dashboards (Auto-Generated)\n\nEvery entity automatically gets a read-only dashboard at `/dashboard/:entity` (e.g., `/dashboard/users`). No configuration needed — just open the URL.\n\nEach dashboard gives you:\n- **Search bar** — filter records by any field\n- **Sortable columns** — click a column header to sort\n- **CSV export** — download as a spreadsheet: add `?export=csv` to the URL\n- **PDF export** — download as PDF: add `?export=pdf` to the URL\n- **Print** — clean print layout (hides nav and buttons)\n\n---\n\n## Full Tutorial: Building a Pizza Delivery App\n\nThis tutorial walks you through building a complete pizza delivery web app using this framework.\nYou will learn migrations, entity EDN configs, seed data, custom handlers (controller/model/view),\nroute registration, and CRUD via the parameter-driven engine.\n\n**What you will build:**\n\n| Feature | How |\n|---|---|\n| Customer catalog | Parameter-driven CRUD at `/admin/clientes` |\n| Product catalog | Parameter-driven CRUD at `/admin/productos` |\n| Delivery driver catalog | Parameter-driven CRUD at `/admin/repartidores` |\n| Order history | Parameter-driven CRUD at `/admin/pedidos` with subgrid detail |\n| **Phone-based order taking** | Custom handler at `/pedido` with JavaScript |\n| **Kitchen dispatch board** | Custom handler at `/despacho` with Kanban cards |\n\n---\n\n### Step 1: Generate a New Project\n\n```bash\ngit clone https://github.com/\u003cuser\u003e/cgen.git\ncd cgen\nlein setup pizza\ncd pizza\n```\n\nThis creates a `pizza/` directory with namespaces renamed, migrations run, and the database seeded\nwith three default users.\n\n---\n\n### Step 2: Configure the App\n\nOpen `resources/config/app-config.edn` and change the basics:\n\n```clojure\n:site-name    \"Pizza\"\n:company-name \"Your Company\"\n:port         3000\n:uploads      \"./uploads/pizza/\"\n```\n\nThe default locale is `:es` (Spanish). Leave everything else as-is for now.\n\n---\n\n### Step 3: Clean Up the Default Entities\n\nThe generated project includes example entities (contactos, cars, siblings, organizations,\ndepartments, employees, etc.). You do not need them for Pizza. Delete them:\n\n```bash\n# Remove example entity EDN files\nrm resources/entities/contactos.edn\nrm resources/entities/cars.edn\nrm resources/entities/siblings.edn\nrm resources/entities/organizations.edn\nrm resources/entities/departments.edn\nrm resources/entities/employees.edn\nrm resources/entities/employee_profiles.edn\nrm resources/entities/employee_projects.edn\nrm resources/entities/employee_skills.edn\nrm resources/entities/projects.edn\nrm resources/entities/skills.edn\n\n# Remove example hook files\nrm src/pizza/hooks/contactos.clj\nrm src/pizza/hooks/cars.clj\nrm src/pizza/hooks/siblings.clj\nrm src/pizza/hooks/organizations.clj\nrm src/pizza/hooks/departments.clj\nrm src/pizza/hooks/employees.clj\nrm src/pizza/hooks/employee_profiles.clj\nrm src/pizza/hooks/employee_projects.clj\nrm src/pizza/hooks/employee_skills.clj\nrm src/pizza/hooks/projects.clj\nrm src/pizza/hooks/skills.clj\n\n# Remove example migration files\nrm resources/migrations/003-contactos.sqlite.up.sql\nrm resources/migrations/003-contactos.sqlite.down.sql\nrm resources/migrations/004-siblings.sqlite.up.sql\nrm resources/migrations/004-siblings.sqlite.down.sql\nrm resources/migrations/005-cars.sqlite.up.sql\nrm resources/migrations/005-cars.sqlite.down.sql\nrm resources/migrations/007-relationship_examples.sqlite.up.sql\nrm resources/migrations/007-relationship_examples.sqlite.down.sql\n```\n\nKeep `001-users`, `002-users_view`, and `006-audit_log` — you need users and audit.\n\nAfter deleting migration files, reset the database so it only contains the tables you keep:\n\n```bash\n# Delete SQLite database so migrations run from scratch\nrm db/pizza.sqlite\n\n# Re-run migrations (only the ones you kept)\nlein migrate\n\n# Seed Example users\nlein database\n```\n\nAlso clean up the uploaded files directory:\n\n```bash\nrm -rf uploads/pizza/*\n```\n\n---\n\n### Step 4: Create Migrations for the Pizza Schema\n\nYou need 5 new tables: `clientes`, `productos`, `repartidores`, `pedidos`, `pedido_detalle`.\n\nCreate `resources/migrations/007-clientes.sqlite.up.sql`:\n\n```sql\nCREATE TABLE IF NOT EXISTS clientes (\n  id          INTEGER PRIMARY KEY AUTOINCREMENT,\n  nombre      TEXT    NOT NULL,\n  telefono    TEXT    NOT NULL,\n  calle       TEXT,\n  colonia     TEXT,\n  municipio   TEXT,\n  referencias TEXT,\n  activo      TEXT    NOT NULL DEFAULT 'T'\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_clientes_telefono ON clientes (telefono);\n```\n\nCreate `resources/migrations/007-clientes.sqlite.down.sql`:\n\n```sql\nDROP TABLE IF EXISTS clientes;\n```\n\nCreate `resources/migrations/008-productos.sqlite.up.sql`:\n\n```sql\nCREATE TABLE IF NOT EXISTS productos (\n  id        INTEGER PRIMARY KEY AUTOINCREMENT,\n  nombre    TEXT    NOT NULL,\n  categoria TEXT    NOT NULL DEFAULT 'Pizza',\n  precio    REAL    NOT NULL DEFAULT 0,\n  activo    TEXT    NOT NULL DEFAULT 'T'\n);\n```\n\nCreate `resources/migrations/008-productos.sqlite.down.sql`:\n\n```sql\nDROP TABLE IF EXISTS productos;\n```\n\nCreate `resources/migrations/009-repartidores.sqlite.up.sql`:\n\n```sql\nCREATE TABLE IF NOT EXISTS repartidores (\n  id       INTEGER PRIMARY KEY AUTOINCREMENT,\n  nombre   TEXT    NOT NULL,\n  telefono TEXT,\n  activo   TEXT    NOT NULL DEFAULT 'T'\n);\n```\n\nCreate `resources/migrations/009-repartidores.sqlite.down.sql`:\n\n```sql\nDROP TABLE IF EXISTS repartidores;\n```\n\nCreate `resources/migrations/010-pedidos.sqlite.up.sql`:\n\n```sql\nCREATE TABLE IF NOT EXISTS pedidos (\n  id             INTEGER PRIMARY KEY AUTOINCREMENT,\n  cliente_id     INTEGER NOT NULL,\n  repartidor_id  INTEGER,\n  tipo           TEXT    NOT NULL DEFAULT 'domicilio',\n  status         TEXT    NOT NULL DEFAULT 'nuevo',\n  total          REAL    NOT NULL DEFAULT 0,\n  paga_con       REAL             DEFAULT 0,\n  cambio         REAL             DEFAULT 0,\n  notas          TEXT,\n  created_at     TEXT             DEFAULT (datetime('now')),\n  FOREIGN KEY (cliente_id)    REFERENCES clientes(id),\n  FOREIGN KEY (repartidor_id) REFERENCES repartidores(id)\n);\n```\n\nCreate `resources/migrations/010-pedidos.sqlite.down.sql`:\n\n```sql\nDROP TABLE IF EXISTS pedidos;\n```\n\nCreate `resources/migrations/011-pedido_detalle.sqlite.up.sql`:\n\n```sql\nCREATE TABLE IF NOT EXISTS pedido_detalle (\n  id              INTEGER PRIMARY KEY AUTOINCREMENT,\n  pedido_id       INTEGER NOT NULL,\n  producto_id     INTEGER NOT NULL,\n  cantidad        INTEGER NOT NULL DEFAULT 1,\n  precio_unitario REAL    NOT NULL DEFAULT 0,\n  subtotal        REAL    NOT NULL DEFAULT 0,\n  FOREIGN KEY (pedido_id)   REFERENCES pedidos(id)   ON DELETE CASCADE,\n  FOREIGN KEY (producto_id) REFERENCES productos(id)\n);\n```\n\nCreate `resources/migrations/011-pedido_detalle.sqlite.down.sql`:\n\n```sql\nDROP TABLE IF EXISTS pedido_detalle;\n```\n\nRun the new migrations:\n\n```bash\nlein migrate\n```\n\n---\n\n### Step 5: Create Entity EDN Configurations\n\nThese define what the parameter-driven engine shows for each table.\n\n**`resources/entities/clientes.edn`** — Customer catalog (full CRUD for admins):\n\n```clojure\n{:entity     :clientes\n :title      \"Clientes\"\n :table      \"clientes\"\n :connection :default\n :rights     [\"U\" \"A\" \"S\"]\n :mode       :parameter-driven\n :menu-category :Catalogos\n :menu-icon  \"bi bi-people\"\n\n :fields [{:id :id         :label \"ID\"         :type :hidden}\n          {:id :nombre     :label \"Nombre\"     :type :text  :placeholder \"Nombre completo...\"}\n          {:id :telefono   :label \"Teléfono\"   :type :text  :placeholder \"686-123-4567\"}\n          {:id :calle      :label \"Calle y #\"  :type :text  :placeholder \"Av. Juárez 123\"}\n          {:id :colonia    :label \"Colonia\"    :type :text  :placeholder \"Colonia...\"}\n          {:id :municipio  :label \"Municipio\"  :type :text  :placeholder \"Mexicali...\"}\n          {:id :referencias :label \"Referencias\" :type :textarea :placeholder \"Frente a la farmacia, portón azul...\"}\n          {:id :activo     :label \"Activo\"     :type :radio :value \"T\"\n           :options [{:id \"activoT\" :value \"T\" :label \"Sí\"}\n                     {:id \"activoF\" :value \"F\" :label \"No\"}]}]\n\n :queries {:list \"SELECT * FROM clientes ORDER BY nombre ASC\"\n           :get  \"SELECT * FROM clientes WHERE id = ?\"}\n\n :actions {:new true :edit true :delete true}}\n```\n\n**`resources/entities/productos.edn`** — Product catalog (Admin/System only):\n\n```clojure\n{:entity     :productos\n :title      \"Productos\"\n :table      \"productos\"\n :connection :default\n :rights     [\"A\" \"S\"]\n :mode       :parameter-driven\n :menu-category :Catalogos\n :menu-icon  \"bi bi-cart\"\n\n :fields [{:id :id        :label \"ID\"        :type :hidden}\n          {:id :nombre    :label \"Nombre\"    :type :text   :placeholder \"Nombre del producto...\"}\n          {:id :categoria :label \"Categoría\" :type :select\n           :options [{:value \"\"       :label \"Seleccionar...\"}\n                     {:value \"Pizza\"  :label \"Pizza\"}\n                     {:value \"Bebida\" :label \"Bebida\"}\n                     {:value \"Extra\"  :label \"Extra\"}\n                     {:value \"Postre\" :label \"Postre\"}]}\n          {:id :precio    :label \"Precio\"    :type :number :placeholder \"0.00\"}\n          {:id :activo    :label \"Activo\"    :type :radio  :value \"T\"\n           :options [{:id \"activoT\" :value \"T\" :label \"Sí\"}\n                     {:id \"activoF\" :value \"F\" :label \"No\"}]}]\n\n :queries {:list \"SELECT * FROM productos ORDER BY categoria, nombre\"\n           :get  \"SELECT * FROM productos WHERE id = ?\"}\n\n :actions {:new true :edit true :delete true}}\n```\n\n**`resources/entities/repartidores.edn`** — Delivery drivers (Admin/System only):\n\n```clojure\n{:entity     :repartidores\n :title      \"Repartidores\"\n :table      \"repartidores\"\n :connection :default\n :rights     [\"A\" \"S\"]\n :mode       :parameter-driven\n :menu-category :Catalogos\n :menu-icon  \"bi bi-bicycle\"\n\n :fields [{:id :id       :label \"ID\"       :type :hidden}\n          {:id :nombre   :label \"Nombre\"   :type :text :placeholder \"Nombre del repartidor...\"}\n          {:id :telefono :label \"Teléfono\" :type :text :placeholder \"686-123-4567\"}\n          {:id :activo   :label \"Activo\"   :type :radio :value \"T\"\n           :options [{:id \"activoT\" :value \"T\" :label \"Sí\"}\n                     {:id \"activoF\" :value \"F\" :label \"No\"}]}]\n\n :queries {:list \"SELECT * FROM repartidores ORDER BY nombre\"\n           :get  \"SELECT * FROM repartidores WHERE id = ?\"}\n\n :actions {:new true :edit true :delete true}}\n```\n\n**`resources/entities/pedidos.edn`** — Order header (no new, just edit/view):\n\n```clojure\n{:entity     :pedidos\n :title      \"Pedidos\"\n :table      \"pedidos\"\n :connection :default\n :rights     [\"A\" \"S\"]\n :mode       :parameter-driven\n :menu-category :Operacion\n :menu-icon  \"bi bi-receipt\"\n\n :fields [{:id :id            :label \"ID\"        :type :hidden}\n          {:id :cliente_id    :label \"Cliente\"   :type :hidden}\n          {:id :repartidor_id :label \"Repartidor\" :type :hidden}\n          {:id :tipo          :label \"Tipo\"      :type :select\n           :options [{:value \"domicilio\" :label \"Domicilio\"}\n                     {:value \"recoger\"   :label \"Recoger en tienda\"}]}\n          {:id :status :label \"Estado\" :type :select\n           :options [{:value \"nuevo\"      :label \"Nuevo\"}\n                     {:value \"preparando\" :label \"Preparando\"}\n                     {:value \"listo\"      :label \"Listo\"}\n                     {:value \"en_ruta\"    :label \"En Ruta\"}\n                     {:value \"entregado\"  :label \"Entregado\"}\n                     {:value \"cancelado\"  :label \"Cancelado\"}]}\n          {:id :total    :label \"Total $\"    :type :number}\n          {:id :paga_con :label \"Paga con $\" :type :number}\n          {:id :cambio   :label \"Cambio $\"   :type :number}\n          {:id :notas    :label \"Notas\"      :type :textarea}\n          {:id :created_at :label \"Fecha\"    :type :datetime}]\n\n :queries {:list \"SELECT p.*, c.nombre as cliente_nombre, r.nombre as repartidor_nombre\n                  FROM pedidos p\n                  JOIN clientes c ON p.cliente_id = c.id\n                  LEFT JOIN repartidores r ON p.repartidor_id = r.id\n                  ORDER BY p.id DESC\"\n           :get  \"SELECT * FROM pedidos WHERE id = ?\"}\n\n :actions {:new false :edit true :delete false}\n\n :subgrids [{:entity      :pedido_detalle\n             :title       \"Detalle del Pedido\"\n             :foreign-key :pedido_id\n             :icon        \"bi bi-list-ul\"\n             :label       \"Productos\"\n             :relationship-type :one-to-many}]}\n```\n\n**`resources/entities/pedido_detalle.edn`** — Order line items (hidden menu, subgrid only):\n\n```clojure\n{:entity     :pedido_detalle\n :title      \"Detalle Pedido\"\n :table      \"pedido_detalle\"\n :connection :default\n :rights     [\"A\" \"S\"]\n :mode       :parameter-driven\n :menu-hidden true\n\n :fields [{:id :id              :label \"ID\"          :type :hidden}\n          {:id :pedido_id       :label \"Pedido ID\"   :type :hidden}\n          {:id :producto_id     :label \"Producto ID\" :type :hidden}\n          {:id :cantidad        :label \"Cantidad\"    :type :number}\n          {:id :precio_unitario :label \"Precio Unit\" :type :decimal :step 0.01 :hidden-in-grid? true}\n          {:id :precio_formatted :label \"Precio\"     :grid-only? true}\n          {:id :subtotal        :label \"Subtotal\"    :type :decimal :step 0.01 :hidden-in-grid? true}\n          {:id :subtotal_formatted :label \"Subtotal\" :grid-only? true}]\n\n :queries {:list \"SELECT\n                  pd.*,\n                  printf('$%.2f',pd.precio_unitario) as precio_formatted,\n                  printf('$%.2f',pd.subtotal) as subtotal_formatted,\n                  pr.nombre as producto_nombre\n                  FROM pedido_detalle pd\n                  JOIN productos pr ON pd.producto_id = pr.id\n                  ORDER BY pd.id\"\n           :get  \"SELECT\n                  pd.*,\n                  printf('$%.2f',pd.precio_unitario) as precio_formatted,\n                  printf('$%.2f',pd.subtotal) as subtotal_formatted,\n                  pr.nombre as producto_nombre\n                  FROM pedido_detalle pd\n                  JOIN productos pr ON pd.producto_id = pr.id\n                  WHERE pd.id = ?\"}\n\n :actions {:new true :edit true :delete false}}\n```\n\nNow start the dev server and verify the entities appear in the menu:\n\n```bash\nlein with-profile dev run\n```\n\nLog in at `http://localhost:3000` with `admin@example.com` / `admin`. You should see\n**Catalogos** (Clientes, Productos, Repartidores) and **Operacion** (Pedidos) in the nav.\n\n---\n\n### Step 6: Seed Sample Data\n\nHaving to manually type in products is tedious. Create a seed file that inserts example data.\n\nCreate `src/pizza/models/seed.clj`:\n\n```clojure\n(ns pizza.models.seed\n  (:require [pizza.models.crud :refer [db Insert-multi Query]]))\n\n(def productos-ejemplo\n  [{:nombre \"Pizza Queso Chica\"        :categoria \"Pizza\" :precio  89.00}\n   {:nombre \"Pizza Queso Mediana\"      :categoria \"Pizza\" :precio 119.00}\n   {:nombre \"Pizza Queso Grande\"       :categoria \"Pizza\" :precio 149.00}\n   {:nombre \"Pizza Pepperoni Chica\"    :categoria \"Pizza\" :precio  99.00}\n   {:nombre \"Pizza Pepperoni Mediana\"  :categoria \"Pizza\" :precio 129.00}\n   {:nombre \"Pizza Pepperoni Grande\"   :categoria \"Pizza\" :precio 159.00}\n   {:nombre \"Pizza Hawaiana Chica\"     :categoria \"Pizza\" :precio  99.00}\n   {:nombre \"Pizza Hawaiana Mediana\"   :categoria \"Pizza\" :precio 129.00}\n   {:nombre \"Pizza Hawaiana Grande\"    :categoria \"Pizza\" :precio 159.00}\n   {:nombre \"Pizza Mexicana Chica\"     :categoria \"Pizza\" :precio 109.00}\n   {:nombre \"Pizza Mexicana Mediana\"   :categoria \"Pizza\" :precio 139.00}\n   {:nombre \"Pizza Mexicana Grande\"    :categoria \"Pizza\" :precio 169.00}\n   {:nombre \"Pizza Suprema Chica\"      :categoria \"Pizza\" :precio 119.00}\n   {:nombre \"Pizza Suprema Mediana\"    :categoria \"Pizza\" :precio 149.00}\n   {:nombre \"Pizza Suprema Grande\"     :categoria \"Pizza\" :precio 179.00}\n   {:nombre \"Refresco 355ml\"           :categoria \"Bebida\" :precio  20.00}\n   {:nombre \"Refresco 600ml\"           :categoria \"Bebida\" :precio  28.00}\n   {:nombre \"Agua Natural 500ml\"       :categoria \"Bebida\" :precio  18.00}\n   {:nombre \"Jugo de Naranja\"          :categoria \"Bebida\" :precio  25.00}\n   {:nombre \"Orilla de Ajo\"            :categoria \"Extra\"  :precio  15.00}\n   {:nombre \"Aderezo Ranch\"            :categoria \"Extra\"  :precio  10.00}\n   {:nombre \"Aderezo BBQ\"              :categoria \"Extra\"  :precio  10.00}\n   {:nombre \"Chile de Árbol\"           :categoria \"Extra\"  :precio   5.00}\n   {:nombre \"Ingrediente Extra\"        :categoria \"Extra\"  :precio  20.00}\n   {:nombre \"Brownie de Chocolate\"     :categoria \"Postre\" :precio  35.00}\n   {:nombre \"Pay de Queso\"             :categoria \"Postre\" :precio  40.00}])\n\n(def repartidores-ejemplo\n  [{:nombre \"Carlos Méndez\" :telefono \"555-101-0001\"}\n   {:nombre \"Laura Gómez\"   :telefono \"555-101-0002\"}\n   {:nombre \"Miguel Ríos\"   :telefono \"555-101-0003\"}])\n\n(defn seed-productos!\n  []\n  (let [existing (Query db [\"SELECT COUNT(*) AS cnt FROM productos\"])]\n    (if (pos? (-\u003e existing first :cnt))\n      (println \"⚠  Productos ya tiene datos.\")\n      (do (Insert-multi db :productos (map #(assoc % :activo \"T\") productos-ejemplo))\n          (println \"✓  Productos insertados.\")))))\n\n(defn seed-repartidores!\n  []\n  (let [existing (Query db [\"SELECT COUNT(*) AS cnt FROM repartidores\"])]\n    (if (pos? (-\u003e existing first :cnt))\n      (println \"⚠  Repartidores ya tiene datos.\")\n      (do (Insert-multi db :repartidores (map #(assoc % :activo \"T\") repartidores-ejemplo))\n          (println \"✓  Repartidores insertados.\")))))\n```\n\nRun the seed from the REPL:\n\n```bash\nlein repl\n```\n\n```clojure\n(require '[pizza.models.seed :refer [seed-productos! seed-repartidores!]])\n(seed-productos!)\n(seed-repartidores!)\n```\n\nRefresh the browser and navigate to `/admin/productos` — you should see 26 products.\nNavigate to `/admin/repartidores` — you should see 3 drivers.\n\n---\n\n### Step 7: Create the Custom Order-Taking Handler (`/pedido`)\n\nThe parameter-driven engine handles CRUD, but taking a phone order needs a custom UI\nwith search-by-phone, product grid with category tabs, quantity buttons, and a receipt.\nThis is the \"20% custom\" that goes in `handlers/`.\n\nGenerate the handler skeleton:\n\n```bash\nlein gen-handler pedido\n```\n\nThis creates `src/pizza/handlers/pedido/controller.clj`, `model.clj`, `view.clj`.\n\n#### 7a. Model — `src/pizza/handlers/pedido/model.clj`\n\n```clojure\n(ns pizza.handlers.pedido.model\n  (:require\n   [clojure.java.jdbc :as j]\n   [clojure.string :as str]\n   [pizza.models.crud :refer [db Query]]))\n\n(defn- normalize-tel [tel]\n  (str/replace (str tel) #\"[\\s\\-]\" \"\"))\n\n(defn- last-id [t-con]\n  (:id (first (j/query t-con [\"SELECT last_insert_rowid() as id\"]))))\n\n(defn buscar-por-telefono [telefono]\n  (first (Query db [\"SELECT * FROM clientes WHERE telefono = ? AND activo = 'T' LIMIT 1\"\n                    (normalize-tel telefono)])))\n\n(defn get-productos []\n  (Query db [\"SELECT * FROM productos WHERE activo = 'T' ORDER BY categoria, nombre\"]))\n\n(defn get-recibo [pedido-id]\n  (let [pedido  (first (Query db [\"SELECT p.*, c.nombre AS cliente_nombre,\n                                          c.telefono AS cliente_tel,\n                                          c.calle, c.colonia, c.municipio, c.referencias\n                                   FROM pedidos p\n                                   JOIN clientes c ON p.cliente_id = c.id\n                                   WHERE p.id = ?\" pedido-id]))\n        detalle (Query db [\"SELECT pd.*, pr.nombre AS producto_nombre\n                            FROM pedido_detalle pd\n                            JOIN productos pr ON pd.producto_id = pr.id\n                            WHERE pd.pedido_id = ?\n                            ORDER BY pd.id\" pedido-id])]\n    {:pedido pedido :detalle detalle}))\n\n(defn crear-cliente! [m]\n  (j/with-db-transaction [t db]\n    (j/insert! t :clientes (update m :telefono normalize-tel))\n    (last-id t)))\n\n(defn guardar-pedido! [cliente-id tipo notas paga-con total items]\n  (j/with-db-transaction [t db]\n    (j/insert! t :pedidos {:cliente_id cliente-id :tipo tipo\n                           :status \"nuevo\" :total total\n                           :paga_con paga-con :cambio (- paga-con total)\n                           :notas notas})\n    (let [pid (last-id t)]\n      (doseq [{:keys [producto_id cantidad precio_unitario]} items]\n        (j/insert! t :pedido_detalle\n                   {:pedido_id pid :producto_id producto_id\n                    :cantidad cantidad :precio_unitario precio_unitario\n                    :subtotal (* cantidad precio_unitario)}))\n      pid)))\n```\n\n#### 7b. View — `src/pizza/handlers/pedido/view.clj`\n\nThis is the largest file because it contains the HTML and JavaScript. The JS lives inline\nin the view (no separate `.js` file) so a junior programmer can see it all in one place.\n\n```clojure\n(ns pizza.handlers.pedido.view\n  (:require [ring.util.anti-forgery :refer [anti-forgery-field]]))\n\n(defn buscar-view []\n  [:div.container.mt-5\n   [:div.row.justify-content-center\n    [:div.col-md-5\n     [:div.card.shadow-lg\n      [:div.card-header.bg-primary.text-white.text-center\n       [:h4.mb-0 [:i.bi.bi-telephone-fill.me-2] \"Tomar Pedido\"]]\n      [:div.card-body.p-4\n       [:form {:method \"POST\" :action \"/pedido/buscar\"}\n        (anti-forgery-field)\n        [:div.mb-4\n         [:label.form-label.fw-bold {:for \"telefono\"} \"Teléfono del cliente\"]\n         [:input.form-control.form-control-lg\n          {:id \"telefono\" :name \"telefono\" :type \"tel\"\n           :placeholder \"686-123-4567\" :autofocus true :required true}]]\n        [:div.d-grid\n         [:button.btn.btn-primary.btn-lg {:type \"submit\"}\n          [:i.bi.bi-search.me-2] \"Buscar\"]]]]]]]])\n\n(defn- cliente-encontrado [cliente]\n  [:div.alert.alert-success.mb-3\n   [:h6.fw-bold [:i.bi.bi-person-check.me-2] (:nombre cliente)]\n   [:small\n    [:span.me-3 [:i.bi.bi-telephone.me-1] (:telefono cliente)]\n    (when-not (clojure.string/blank? (:calle cliente))\n      [:span [:i.bi.bi-geo-alt.me-1] (:calle cliente) \", \" (:colonia cliente)])]\n   [:input {:type \"hidden\" :name \"cliente_id\" :value (:id cliente)}]\n   [:input {:type \"hidden\" :name \"telefono\"   :value (:telefono cliente)}]])\n\n(defn- nuevo-cliente-form [telefono]\n  [:div.card.border-warning.mb-3\n   [:div.card-header.bg-warning.text-dark.fw-bold\n    [:i.bi.bi-person-plus.me-2] \"Cliente nuevo — registrar\"]\n   [:div.card-body\n    [:div.row.g-2\n     [:div.col-md-6\n      [:label.form-label.fw-semibold {:for \"nombre\"} \"Nombre *\"]\n      [:input.form-control {:id \"nombre\" :name \"nombre\" :type \"text\"\n                            :placeholder \"Nombre completo\" :required true}]]\n     [:div.col-md-6\n      [:label.form-label.fw-semibold {:for \"telefono\"} \"Teléfono *\"]\n      [:input.form-control {:id \"telefono\" :name \"telefono\" :type \"tel\"\n                            :value telefono :required true}]]\n     [:div.col-md-8\n      [:label.form-label.fw-semibold {:for \"calle\"} \"Calle y número\"]\n      [:input.form-control {:id \"calle\" :name \"calle\" :type \"text\"\n                            :placeholder \"Av. Juárez 123\"}]]\n     [:div.col-md-4\n      [:label.form-label.fw-semibold {:for \"colonia\"} \"Colonia\"]\n      [:input.form-control {:id \"colonia\" :name \"colonia\" :type \"text\"\n                            :placeholder \"Colonia\"}]]\n     [:div.col-12\n      [:label.form-label.fw-semibold {:for \"referencias\"} \"Referencias\"]\n      [:input.form-control {:id \"referencias\" :name \"referencias\" :type \"text\"\n                            :placeholder \"Frente a la farmacia, portón azul...\"}]]]]])\n\n(defn- producto-card [p]\n  [:div.col-6.col-md-4.col-lg-3\n   [:div.card.h-100.shadow-sm.text-center\n    [:div.card-body.p-2.d-flex.flex-column.justify-content-between\n     [:div.fw-semibold.mb-2 {:style \"font-size:0.9rem; line-height:1.3;\"}\n      (:nombre p)]\n     [:div\n      [:div.text-success.fw-bold.fs-5.mb-2\n       (str \"$\" (format \"%.0f\" (double (:precio p))))]\n      [:div.d-flex.justify-content-center.align-items-center.gap-1\n       [:button.btn.btn-outline-secondary.btn-sm\n        {:type \"button\" :onclick (str \"adjQty('qty-\" (:id p) \"',-1)\")} \"−\"]\n       [:input.form-control.form-control-sm.text-center.qty-input\n        {:type \"number\" :name (str \"qty-\" (:id p)) :value \"0\"\n         :min \"0\" :max \"99\" :style \"width:52px;\"\n         :data-precio (str (:precio p)) :onchange \"calcularTotal()\"}]\n       [:button.btn.btn-outline-primary.btn-sm\n        {:type \"button\" :onclick (str \"adjQty('qty-\" (:id p) \"',1)\")} \"+\"]]]]]])\n\n(defn- productos-section [productos]\n  (let [grouped (group-by :categoria productos)\n        cats (sort (keys grouped))\n        first-cat (first cats)\n        n (count cats)\n        indexed (map-indexed vector cats)]\n    [:div.mb-3\n     [:div.d-flex.flex-wrap.gap-2.mb-3\n      (for [[i cat] indexed]\n        [:button {:type \"button\" :id (str \"btn-cat-\" i)\n                  :class (if (= cat first-cat) \"btn btn-primary btn-sm\" \"btn btn-outline-secondary btn-sm\")\n                  :onclick (str \"showCat(\" i \",\" n \")\")} cat])]\n     (for [[i cat] indexed]\n       [:div {:id (str \"cat-pane-\" i)\n              :style (if (= cat first-cat) \"display:block;\" \"display:none;\")}\n        [:div.row.g-2 (map producto-card (get grouped cat))]])]))\n\n(defn orden-view [{:keys [cliente telefono productos]}]\n  (let [no-productos? (empty? productos)]\n    [:div.container-fluid\n     [:form#pedido-form {:method \"POST\" :action \"/pedido/guardar\"}\n      (anti-forgery-field)\n      [:input {:type \"hidden\" :name \"total\" :id \"total-hidden\" :value \"0\"}]\n      [:div.row.g-2.mb-5\n       [:div.col-lg-8\n        [:div.card.shadow-sm.mb-2\n         [:div.card-header.bg-secondary.text-white.fw-bold.py-1\n          [:i.bi.bi-person.me-1] \"Cliente\"]\n         [:div.card-body.py-2\n          (if cliente (cliente-encontrado cliente) (nuevo-cliente-form telefono))]]\n        [:div.card.shadow-sm\n         [:div.card-header.bg-secondary.text-white.fw-bold.py-1\n          [:i.bi.bi-grid.me-1] \"Productos\"]\n         [:div.card-body.p-2\n          (if no-productos?\n            [:div.alert.alert-warning.m-2 \"No hay productos activos.\"]\n            (productos-section productos))]]]\n       [:div.col-lg-4\n        [:div.card.shadow-sm.mb-2\n         [:div.card-header.bg-secondary.text-white.fw-bold.py-1\n          [:i.bi.bi-truck.me-1] \"Entrega\"]\n         [:div.card-body.py-2\n          [:div.form-check\n           [:input.form-check-input {:type \"radio\" :name \"tipo\" :id \"t1\"\n                                     :value \"domicilio\" :checked true}]\n           [:label.form-check-label {:for \"t1\"} \"A domicilio\"]]\n          [:div.form-check\n           [:input.form-check-input {:type \"radio\" :name \"tipo\" :id \"t2\"\n                                     :value \"recoger\"}]\n           [:label.form-check-label {:for \"t2\"} \"Recoger en tienda\"]]]]\n        [:div.card.shadow-sm.mb-2\n         [:div.card-header.bg-secondary.text-white.fw-bold.py-1\n          [:i.bi.bi-cash.me-1] \"Pago\"]\n         [:div.card-body.py-2\n          [:label.form-label.fw-semibold.small {:for \"paga-con\"} \"¿Con cuánto paga?\"]\n          [:div.input-group\n           [:span.input-group-text \"$\"]\n           [:input.form-control.form-control-lg\n            {:id \"paga-con\" :type \"number\" :name \"paga_con\" :value \"0\"\n             :min \"0\" :step \"1\" :onchange \"calcularCambio()\" :oninput \"calcularCambio()\"}]]]]\n        [:div.card.shadow-sm\n         [:div.card-header.bg-secondary.text-white.fw-bold.py-1\n          [:i.bi.bi-chat-left-text.me-1] \"Notas\"]\n         [:div.card-body.py-2\n          [:input.form-control {:type \"text\" :name \"notas\"\n                                :placeholder \"Sin jalapeños, extra queso...\"}]]]]]\n      [:div {:style \"position:fixed;bottom:0;left:0;right:0;z-index:1040;\n                     background:#212529;color:#fff;padding:0.5rem 1.5rem;\n                     display:flex;align-items:center;justify-content:space-between;gap:1rem;\n                     box-shadow:0 -2px 8px rgba(0,0,0,0.3);\"}\n       [:div.d-flex.gap-4.align-items-center\n        [:div\n         [:div {:style \"font-size:0.7rem;color:#adb5bd;\"} \"TOTAL\"]\n         [:div.fw-bold.fs-4 {:id \"total-display\"} \"$0.00\"]]\n        [:div\n         [:div {:style \"font-size:0.7rem;color:#adb5bd;\"} \"CAMBIO\"]\n         [:div.fw-bold.fs-4 {:id \"cambio-display\"} \"$0.00\"]]]\n       [:div.d-flex.gap-2.align-items-center\n        [:a.btn.btn-outline-light.btn-sm {:href \"/pedido\"}\n         [:i.bi.bi-arrow-left.me-1] \"Nueva búsqueda\"]\n        [:button.btn.btn-success.btn-lg.px-4 {:type \"submit\"}\n         [:i.bi.bi-check-circle.me-2] \"Guardar Pedido\"]]]]]))\n\n(defn recibo-view [pedido detalle]\n  (let [cambio (:cambio pedido 0) tipo (:tipo pedido)]\n    [:div.container.mt-4\n     [:style \"@media print {\n       .no-print { display:none !important; }\n       nav, .navbar { display:none !important; }\n       .card { border:none !important; box-shadow:none !important; }\n       .card-header { background:#000 !important; color:#fff !important;\n                      -webkit-print-color-adjust:exact; print-color-adjust:exact; }\n       body { margin:0 !important; padding:0 !important; }\n     }\"]\n     [:div.card.shadow-lg\n      [:div.card-header.bg-success.text-white\n       [:div.d-flex.justify-content-between.align-items-center\n        [:h4.mb-0 [:i.bi.bi-receipt.me-2] \"Pedido #\" (:id pedido)]\n        [:span.badge.bg-light.text-dark.fs-6\n         (if (= tipo \"domicilio\") \"Domicilio\" \"Recoger en tienda\")]]]\n      [:div.card-body\n       [:div.mb-3\n        [:h6.fw-bold [:i.bi.bi-person.me-2] \"Cliente\"]\n        [:p.mb-0 (:cliente_nombre pedido)]\n        (when (= tipo \"domicilio\")\n          [:p.mb-0.text-muted\n           (:calle pedido) \", Col. \" (:colonia pedido)\n           (when-not (clojure.string/blank? (:referencias pedido))\n             [:span.d-block.fst-italic \"Ref: \" (:referencias pedido)])])]\n       [:hr]\n       [:table.table.table-sm\n        [:thead [:tr [:th \"Producto\"] [:th.text-center \"Cant\"] [:th.text-end \"Subtotal\"]]]\n        [:tbody (for [d detalle]\n                  [:tr {:key (:id d)}\n                   [:td (:producto_nombre d)]\n                   [:td.text-center (:cantidad d)]\n                   [:td.text-end (format \"$%.2f\" (double (:subtotal d)))]])]\n        [:tfoot [:tr.fw-bold [:td {:colspan \"2\"} \"TOTAL\"]\n                 [:td.text-end (format \"$%.2f\" (double (:total pedido 0)))]]]]\n       [:hr]\n       [:div.row.text-center\n        [:div.col-6 [:div.text-muted.small \"Paga con\"]\n         [:div.fs-4.fw-bold (format \"$%.2f\" (double (:paga_con pedido 0)))]]\n        [:div.col-6 [:div.text-muted.small \"Cambio\"]\n         [:div.fs-2.fw-bold {:class (if (\u003e= cambio 0) \"text-success\" \"text-danger\")}\n          (format \"$%.2f\" (double cambio))]]]]\n      [:div.card-footer.no-print.d-flex.gap-2\n       [:a.btn.btn-primary {:href \"/pedido\"} [:i.bi.bi-telephone.me-1] \"Nuevo Pedido\"]\n       [:a.btn.btn-secondary {:href \"/despacho\"} [:i.bi.bi-truck.me-1] \"Ir a Despacho\"]\n       [:button.btn.btn-outline-dark {:type \"button\" :onclick \"window.print()\"}\n        [:i.bi.bi-printer.me-1] \"Imprimir\"]]]]))\n\n(defn orden-js []\n  [:script\n   \"function showCat(idx,total){\n      for(var j=0;j\u003ctotal;j++){\n        var p=document.getElementById('cat-pane-'+j);\n        var b=document.getElementById('btn-cat-'+j);\n        if(p)p.style.display=(j===idx)?'block':'none';\n        if(b)b.className=(j===idx)?'btn btn-primary btn-sm':'btn btn-outline-secondary btn-sm';\n      }\n    }\n    function adjQty(name,delta){\n      var el=document.querySelector('input[name=\\\"'+name+'\\\"]');\n      el.value=Math.max(0,Math.min(99,(parseInt(el.value,10)||0)+delta));\n      calcularTotal();\n    }\n    function calcularTotal(){\n      var t=0;\n      document.querySelectorAll('.qty-input').forEach(function(el){\n        t+=(parseInt(el.value,10)||0)*(parseFloat(el.dataset.precio)||0);\n      });\n      document.getElementById('total-display').textContent='$'+t.toFixed(2);\n      document.getElementById('total-hidden').value=t.toFixed(2);\n      calcularCambio();\n    }\n    function calcularCambio(){\n      var t=parseFloat(document.getElementById('total-hidden').value)||0;\n      var p=parseFloat(document.getElementById('paga-con').value)||0;\n      var c=p-t;\n      var el=document.getElementById('cambio-display');\n      el.textContent=c\u003e=0?'$'+c.toFixed(2):'Insuficiente';\n      el.className=c\u003e=0?'fw-bold fs-4 text-success':'fw-bold fs-4 text-danger';\n    }\n    document.addEventListener('DOMContentLoaded',function(){\n      calcularTotal();\n      document.getElementById('pedido-form').addEventListener('submit',function(e){\n        if((parseFloat(document.getElementById('total-hidden').value)||0)\u003c=0){\n          e.preventDefault(); alert('Seleccione al menos un producto.');\n        }\n      });\n    });\"])\n```\n\n#### 7c. Controller — `src/pizza/handlers/pedido/controller.clj`\n\n```clojure\n(ns pizza.handlers.pedido.controller\n  (:require\n   [clojure.string :as str]\n   [pizza.handlers.pedido.model :as model]\n   [pizza.handlers.pedido.view  :as view]\n   [pizza.layout :refer [application]]\n   [pizza.models.util :refer [get-session-id]]\n   [ring.util.response :refer [redirect]]))\n\n(defn- parse-int [s]\n  (try (Integer/parseInt (str s)) (catch Exception _ 0)))\n\n(defn- str-\u003edouble [s]\n  (try (Double/parseDouble (str s)) (catch Exception _ 0.0)))\n\n(defn buscar [request]\n  (application request \"Tomar Pedido\" (get-session-id request) nil (view/buscar-view)))\n\n(defn buscar-post [request]\n  (let [telefono  (str/trim (get-in request [:params :telefono] \"\"))\n        cliente   (when-not (str/blank? telefono)\n                    (model/buscar-por-telefono telefono))\n        productos (model/get-productos)]\n    (application request \"Tomar Pedido\" (get-session-id request)\n                 (view/orden-js)\n                 (view/orden-view {:cliente cliente :telefono telefono :productos productos}))))\n\n(defn guardar [request]\n  (let [params     (:params request)\n        cliente-id (when-let [s (:cliente_id params)]\n                     (parse-int s))\n        tipo    (or (:tipo params) \"domicilio\")\n        notas   (or (:notas params) \"\")\n        paga-con (str-\u003edouble (:paga_con params))\n        productos  (model/get-productos)\n        precio-map (into {} (map (fn [p] [(:id p) (:precio p)]) productos))\n        items (-\u003e\u003e params\n                   (filter (fn [[k _]] (str/starts-with? (name k) \"qty-\")))\n                   (keep (fn [[k v]]\n                           (let [qty (parse-int v)\n                                 pid (parse-int (subs (name k) 4))\n                                 precio (get precio-map pid 0.0)]\n                             (when (and (pos? qty) (pos? pid))\n                               {:producto_id pid :cantidad qty\n                                :precio_unitario precio}))))\n                   vec)\n        total (reduce + 0.0 (map #(* (:cantidad %) (:precio_unitario %)) items))]\n    (if (empty? items)\n      (redirect \"/pedido\")\n      (let [cid (or cliente-id\n                    (model/crear-cliente!\n                     {:nombre (:nombre params \"\") :telefono (:telefono params \"\")\n                      :calle (:calle params \"\") :colonia (:colonia params \"\")\n                      :municipio (:municipio params \"\") :referencias (:referencias params \"\")\n                      :activo \"T\"}))\n            pid (model/guardar-pedido! cid tipo notas paga-con total items)]\n        (redirect (str \"/pedido/recibo/\" pid))))))\n\n(defn recibo [request]\n  (let [id-str (get-in request [:params :id])\n        pid (try (Long/parseLong (str id-str)) (catch Exception _ nil))]\n    (if pid\n      (let [{:keys [pedido detalle]} (model/get-recibo pid)]\n        (application request \"Recibo de Pedido\" (get-session-id request) nil\n                     (view/recibo-view pedido detalle)))\n      (redirect \"/pedido\"))))\n```\n\n---\n\n### Step 8: Register the Pedido Routes\n\nEdit `src/pizza/routes/proutes.clj` to add the pedido and despacho routes:\n\n```clojure\n(ns pizza.routes.proutes\n  (:require\n   [compojure.core :refer [defroutes GET POST]]\n   [pizza.handlers.pedido.controller   :as pedido]\n   [pizza.handlers.despacho.controller :as despacho]))\n\n(defroutes proutes\n  ;; Pedido — order taking\n  (GET  \"/pedido\"            req (pedido/buscar req))\n  (POST \"/pedido/buscar\"     req (pedido/buscar-post req))\n  (POST \"/pedido/guardar\"    req (pedido/guardar req))\n  (GET  \"/pedido/recibo/:id\" req (pedido/recibo req))\n\n  ;; Despacho — kitchen/dispatch board\n  (GET  \"/despacho\"          req (despacho/main req))\n  (POST \"/despacho/status\"   req (despacho/cambiar-status req))\n  (POST \"/despacho/asignar\"  req (despacho/asignar req)))\n```\n\nIf the `gen-handler` command already added the require for pedido, leave it. Add the one for\ndespacho.\n\n---\n\n### Step 9: Create the Kitchen Dispatch Handler (`/despacho`)\n\nGenerate the skeleton:\n\n```bash\nlein gen-handler despacho\n```\n\n#### 9a. Model — `src/pizza/handlers/despacho/model.clj`\n\n```clojure\n(ns pizza.handlers.despacho.model\n  (:require [pizza.models.crud :refer [db Query Update]]))\n\n(def ^:private open-statuses \"('nuevo','preparando','listo','en_ruta')\")\n\n(defn get-pedidos-abiertos []\n  (Query db [(str \"SELECT p.*, c.nombre AS cliente_nombre, c.telefono AS cliente_tel,\n                          c.calle, c.colonia, c.referencias,\n                          r.nombre AS repartidor_nombre\n                   FROM pedidos p\n                   JOIN clientes c ON p.cliente_id = c.id\n                   LEFT JOIN repartidores r ON p.repartidor_id = r.id\n                   WHERE p.status IN \" open-statuses \"\n                   ORDER BY p.id ASC\")]))\n\n(defn get-repartidores []\n  (Query db [\"SELECT * FROM repartidores WHERE activo = 'T' ORDER BY nombre\"]))\n\n(defn cambiar-status! [pedido-id status]\n  (Update db :pedidos {:status status} [\"id = ?\" pedido-id]))\n\n(defn asignar! [pedido-ids repartidor-id]\n  (doseq [pid pedido-ids]\n    (Update db :pedidos {:repartidor_id repartidor-id :status \"en_ruta\"}\n            [\"id = ?\" pid])))\n```\n\n#### 9b. View — `src/pizza/handlers/despacho/view.clj`\n\n```clojure\n(ns pizza.handlers.despacho.view\n  (:require [clojure.string :as str]\n            [ring.util.anti-forgery :refer [anti-forgery-field]]))\n\n(def ^:private status-cfg\n  {\"nuevo\"      {:label \"Nuevo\"      :color \"danger\"  :next \"preparando\" :next-label \"→ Preparando\"}\n   \"preparando\" {:label \"Preparando\" :color \"warning\" :next \"listo\"      :next-label \"→ Listo\"}\n   \"listo\"      {:label \"Listo\"      :colo","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhectorqlucero%2Fcgen","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhectorqlucero%2Fcgen","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhectorqlucero%2Fcgen/lists"}