commit 11e94889b294d89716edb73f703a63e0394839ad Author: Ghassan Yusuf Date: Tue May 19 12:40:08 2026 +0300 chore: initial commit of existing codebase diff --git a/.claude/mcp-server.py b/.claude/mcp-server.py new file mode 100644 index 0000000..33977a1 --- /dev/null +++ b/.claude/mcp-server.py @@ -0,0 +1,375 @@ +""" +OperationModule Database MCP Server +Gives Claude direct read-only access to the SQLite database so queries +don't require reading PHP files or running artisan commands. +""" + +import sqlite3 +import json +import sys +import os +from pathlib import Path + +# ── MCP SDK ────────────────────────────────────────────────────────────────── +from mcp.server.fastmcp import FastMCP + +DB_PATH = Path(__file__).parent.parent / "database" / "database.sqlite" + +mcp = FastMCP("OperationModule DB") + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _conn() -> sqlite3.Connection: + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def _rows(sql: str, params: tuple = ()) -> list[dict]: + with _conn() as conn: + cur = conn.execute(sql, params) + return [dict(r) for r in cur.fetchall()] + + +def _one(sql: str, params: tuple = ()) -> dict | None: + rows = _rows(sql, params) + return rows[0] if rows else None + + +# ───────────────────────────────────────────────────────────────────────────── +# Schema & Meta +# ───────────────────────────────────────────────────────────────────────────── + +@mcp.tool() +def list_tables() -> str: + """List all tables in the database with row counts.""" + rows = _rows("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + result = [] + with _conn() as conn: + for r in rows: + t = r["name"] + count = conn.execute(f"SELECT COUNT(*) FROM [{t}]").fetchone()[0] + result.append(f"{t}: {count} rows") + return "\n".join(result) + + +@mcp.tool() +def describe_table(table: str) -> str: + """Return the column definitions for a table.""" + rows = _rows(f"PRAGMA table_info([{table}])") + if not rows: + return f"Table '{table}' not found." + lines = [f"Table: {table}"] + for c in rows: + nullable = "" if c["notnull"] else " NULL" + default = f" DEFAULT {c['dflt_value']}" if c["dflt_value"] is not None else "" + pk = " PK" if c["pk"] else "" + lines.append(f" {c['name']} {c['type']}{nullable}{default}{pk}") + return "\n".join(lines) + + +@mcp.tool() +def run_query(sql: str) -> str: + """ + Execute a read-only SQL query and return results as JSON. + Only SELECT statements are allowed. Max 200 rows returned. + """ + stripped = sql.strip().upper() + if not stripped.startswith("SELECT") and not stripped.startswith("WITH"): + return "Error: only SELECT / WITH queries are permitted." + try: + rows = _rows(sql) + if len(rows) > 200: + rows = rows[:200] + suffix = "\n[Truncated to 200 rows]" + else: + suffix = "" + return json.dumps(rows, default=str, indent=2) + suffix + except Exception as e: + return f"Error: {e}" + + +# ───────────────────────────────────────────────────────────────────────────── +# Purchase Module +# ───────────────────────────────────────────────────────────────────────────── + +@mcp.tool() +def get_suppliers(active_only: bool = False) -> str: + """List all suppliers with their contact details.""" + where = "WHERE is_active = 1" if active_only else "" + rows = _rows(f""" + SELECT id, name, contact_person, email, phone, address, tax_number, + CASE WHEN is_active THEN 'Active' ELSE 'Inactive' END AS status + FROM suppliers {where} + ORDER BY name + """) + return json.dumps(rows, default=str, indent=2) + + +@mcp.tool() +def get_purchase_orders(status: str = "") -> str: + """ + List purchase orders. Optional status filter: draft|sent|partial|received|cancelled + """ + where = "WHERE po.status = ?" if status else "" + params = (status,) if status else () + rows = _rows(f""" + SELECT po.id, po.po_number, s.name AS supplier, po.po_date, + po.expected_delivery_date, po.total_amount, po.status + FROM purchase_orders po + JOIN suppliers s ON s.id = po.supplier_id + {where} + ORDER BY po.po_date DESC + LIMIT 100 + """, params) + return json.dumps(rows, default=str, indent=2) + + +@mcp.tool() +def get_purchase_requests(status: str = "") -> str: + """ + List purchase requests. Optional status: pending|approved|rejected|ordered + """ + where = "WHERE pr.status = ?" if status else "" + params = (status,) if status else () + rows = _rows(f""" + SELECT pr.id, pr.request_number, pr.date, pr.department, + pr.requested_by_name, pr.required_date_text, pr.status, + COUNT(pri.id) AS item_count + FROM purchase_requests pr + LEFT JOIN purchase_request_items pri ON pri.purchase_request_id = pr.id + {where} + GROUP BY pr.id + ORDER BY pr.date DESC + LIMIT 100 + """, params) + return json.dumps(rows, default=str, indent=2) + + +@mcp.tool() +def get_supplier_invoices(status: str = "") -> str: + """List supplier invoices. Optional status: unpaid|partial|paid""" + where = "WHERE si.status = ?" if status else "" + params = (status,) if status else () + rows = _rows(f""" + SELECT si.id, si.invoice_number, s.name AS supplier, + si.invoice_date, si.due_date, si.total_amount, + si.paid_amount, si.status + FROM supplier_invoices si + JOIN suppliers s ON s.id = si.supplier_id + {where} + ORDER BY si.invoice_date DESC + LIMIT 100 + """, params) + return json.dumps(rows, default=str, indent=2) + + +# ───────────────────────────────────────────────────────────────────────────── +# Inventory Module +# ───────────────────────────────────────────────────────────────────────────── + +@mcp.tool() +def get_items(category: str = "", active_only: bool = False) -> str: + """ + List inventory items. + category: raw_material | wip | finished_good (blank = all) + """ + conditions = [] + params: list = [] + if category: + conditions.append("category = ?") + params.append(category) + if active_only: + conditions.append("is_active = 1") + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + rows = _rows(f""" + SELECT id, item_code, item_name, category, unit_of_measure, + cost_price, minimum_stock_level, + CASE WHEN is_active THEN 'Active' ELSE 'Inactive' END AS status + FROM items {where} + ORDER BY category, item_name + """, tuple(params)) + return json.dumps(rows, default=str, indent=2) + + +@mcp.tool() +def get_stock_levels(warehouse_id: int = 0, low_stock_only: bool = False) -> str: + """ + Current stock levels, optionally filtered by warehouse or showing only low-stock items. + """ + conditions = [] + params: list = [] + if warehouse_id: + conditions.append("sl.warehouse_id = ?") + params.append(warehouse_id) + if low_stock_only: + conditions.append("sl.quantity <= i.minimum_stock_level") + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + rows = _rows(f""" + SELECT i.item_code, i.item_name, i.category, i.unit_of_measure, + w.name AS warehouse, sl.quantity, + i.minimum_stock_level, + ROUND(sl.quantity * i.cost_price, 2) AS stock_value + FROM stock_levels sl + JOIN items i ON i.id = sl.item_id + JOIN warehouses w ON w.id = sl.warehouse_id + {where} + ORDER BY i.category, i.item_name + """, tuple(params)) + return json.dumps(rows, default=str, indent=2) + + +@mcp.tool() +def get_warehouses() -> str: + """List all warehouses.""" + rows = _rows(""" + SELECT w.id, w.code, w.name, w.location, + CASE WHEN w.is_active THEN 'Active' ELSE 'Inactive' END AS status, + COUNT(sl.id) AS item_types, + ROUND(SUM(sl.quantity * i.cost_price), 2) AS total_value + FROM warehouses w + LEFT JOIN stock_levels sl ON sl.warehouse_id = w.id + LEFT JOIN items i ON i.id = sl.item_id + GROUP BY w.id + ORDER BY w.name + """) + return json.dumps(rows, default=str, indent=2) + + +# ───────────────────────────────────────────────────────────────────────────── +# Production Module +# ───────────────────────────────────────────────────────────────────────────── + +@mcp.tool() +def get_production_orders(status: str = "") -> str: + """ + List production orders. + status: planned|in_progress|completed|cancelled + """ + where = "WHERE po.status = ?" if status else "" + params = (status,) if status else () + rows = _rows(f""" + SELECT po.id, po.order_number, i.item_name AS product, + po.quantity_to_produce, po.quantity_produced, + po.production_date, po.completion_date, po.status + FROM production_orders po + JOIN items i ON i.id = po.product_id + {where} + ORDER BY po.production_date DESC + LIMIT 100 + """, params) + return json.dumps(rows, default=str, indent=2) + + +@mcp.tool() +def get_bill_of_materials(product_id: int = 0) -> str: + """ + Show bill of materials (recipes). Pass product_id to filter to one product. + """ + where = "WHERE bom.product_id = ?" if product_id else "" + params = (product_id,) if product_id else () + rows = _rows(f""" + SELECT p.item_name AS product, r.item_name AS raw_material, + bom.quantity_required, bom.unit_of_measure + FROM bill_of_materials bom + JOIN items p ON p.id = bom.product_id + JOIN items r ON r.id = bom.raw_material_id + {where} + ORDER BY p.item_name, r.item_name + """, params) + return json.dumps(rows, default=str, indent=2) + + +# ───────────────────────────────────────────────────────────────────────────── +# Sales Module +# ───────────────────────────────────────────────────────────────────────────── + +@mcp.tool() +def get_customers(active_only: bool = False) -> str: + """List all customers with outstanding balances.""" + where = "WHERE is_active = 1" if active_only else "" + rows = _rows(f""" + SELECT id, name, contact_person, email, phone, + credit_limit, outstanding_balance, + CASE WHEN is_active THEN 'Active' ELSE 'Inactive' END AS status + FROM customers {where} + ORDER BY name + """) + return json.dumps(rows, default=str, indent=2) + + +@mcp.tool() +def get_sales_orders(status: str = "") -> str: + """ + List sales orders. + status: draft|confirmed|dispatched|invoiced|cancelled + """ + where = "WHERE so.status = ?" if status else "" + params = (status,) if status else () + rows = _rows(f""" + SELECT so.id, so.order_number, c.name AS customer, + so.order_date, so.delivery_date, + so.total_amount, so.status + FROM sales_orders so + JOIN customers c ON c.id = so.customer_id + {where} + ORDER BY so.order_date DESC + LIMIT 100 + """, params) + return json.dumps(rows, default=str, indent=2) + + +@mcp.tool() +def get_sales_invoices(status: str = "") -> str: + """List sales invoices. status: unpaid|partial|paid|cancelled""" + where = "WHERE si.status = ?" if status else "" + params = (status,) if status else () + rows = _rows(f""" + SELECT si.id, si.invoice_number, c.name AS customer, + si.invoice_date, si.due_date, si.total_amount, + si.paid_amount, si.status + FROM sales_invoices si + JOIN customers c ON c.id = si.customer_id + {where} + ORDER BY si.invoice_date DESC + LIMIT 100 + """, params) + return json.dumps(rows, default=str, indent=2) + + +# ───────────────────────────────────────────────────────────────────────────── +# Dashboard / Summary +# ───────────────────────────────────────────────────────────────────────────── + +@mcp.tool() +def get_dashboard_summary() -> str: + """ + High-level KPIs: inventory value, open POs, open sales orders, + unpaid supplier invoices, unpaid sales invoices, active items/suppliers. + """ + with _conn() as conn: + def scalar(sql, p=()): + r = conn.execute(sql, p).fetchone() + return r[0] if r else 0 + + summary = { + "suppliers": scalar("SELECT COUNT(*) FROM suppliers WHERE is_active=1"), + "items_active": scalar("SELECT COUNT(*) FROM items WHERE is_active=1"), + "inventory_value": scalar("SELECT ROUND(SUM(sl.quantity*i.cost_price),2) FROM stock_levels sl JOIN items i ON i.id=sl.item_id"), + "low_stock_items": scalar("SELECT COUNT(*) FROM stock_levels sl JOIN items i ON i.id=sl.item_id WHERE sl.quantity<=i.minimum_stock_level AND i.minimum_stock_level>0"), + "open_purchase_orders": scalar("SELECT COUNT(*) FROM purchase_orders WHERE status IN ('draft','sent','partial')"), + "pending_requests": scalar("SELECT COUNT(*) FROM purchase_requests WHERE status='pending'"), + "open_production_orders": scalar("SELECT COUNT(*) FROM production_orders WHERE status IN ('planned','in_progress')"), + "open_sales_orders": scalar("SELECT COUNT(*) FROM sales_orders WHERE status IN ('draft','confirmed')"), + "unpaid_supplier_invoices": scalar("SELECT ROUND(SUM(total_amount-paid_amount),2) FROM supplier_invoices WHERE status IN ('unpaid','partial')"), + "unpaid_sales_invoices": scalar("SELECT ROUND(SUM(total_amount-paid_amount),2) FROM sales_invoices WHERE status IN ('unpaid','partial')"), + } + return json.dumps(summary, default=str, indent=2) + + +# ───────────────────────────────────────────────────────────────────────────── +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0f243d7 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "superpowers@claude-plugins-official": true + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[compose.yaml] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0660ea --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b71b1ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/.superpowers/brainstorm/1542-1779106932/content/architecture.html b/.superpowers/brainstorm/1542-1779106932/content/architecture.html new file mode 100644 index 0000000..ed94519 --- /dev/null +++ b/.superpowers/brainstorm/1542-1779106932/content/architecture.html @@ -0,0 +1,169 @@ +

Purchase Pipeline — Architecture

+

7 stages, 3 new tables, everything else reuses existing models

+ +
+ + +
+
The 7 Stages
+ +
+ + +
+
+
1
+
+
+
+
Purchase Request
+
Operations team fills in items needed
+
EXISTING · purchase_requests
+
+
+ + +
+
+
2
+
+
+
+
GM Digital Signature
+
GM draws signature on canvas, saved as image
+
NEW · purchase_signatures
+
+
+ + +
+
+
3
+
+
+
+
Select Suppliers → Send RFQ Links
+
Pick suppliers from list, system sends unique links via WhatsApp/Email
+
NEW · rfq_invitations
+
+
+ + +
+
+
4
+
+
+
+
Suppliers Submit Quotes
+
Each supplier opens their private link, fills price + terms, submits
+
NEW · supplier_quotes + quote_items
+
+
+ + +
+
+
5
+
+
+
+
Quote Comparison & Award
+
Side-by-side table, team picks winner, records decision reason
+
NEW · supplier_quotes (awarded flag)
+
+
+ + +
+
+
6
+
+
+
+
Issue LPO
+
Create & send Local Purchase Order to winning supplier
+
EXISTING · purchase_orders (LPO)
+
+
+ + +
+
+
7
+
+
+
+
Receive Materials at Site
+
GRN created — each item flagged Inventory or Consumable
+
EXISTING · goods_receipt_notes + grn_items
+
+
+ + +
+
+
8
+
+
+
Issue Payment / Cheque
+
Record cheque number, bank, amount, payment date
+
EXISTING · supplier_payments
+
+
+ +
+
+ + +
+
3 New Database Tables
+ +
+
purchase_signatures
+
+ id
purchase_request_id → FK
signed_by → FK users
signature_image → base64 png
signed_at → timestamp
ip_address +
+
+ +
+
rfq_invitations
+
+ id
purchase_request_id → FK
supplier_id → FK
token → unique 64-char hex
channel → email|whatsapp|both
sent_at, opened_at, expires_at
status → pending|opened|submitted|declined +
+
+ +
+
supplier_quotes + supplier_quote_items
+
+ id
rfq_invitation_id → FK
submitted_at
lead_time_days
payment_terms
notes
total_amount
is_awarded → boolean
award_reason
── items ──
description, unit, qty, unit_price, total +
+
+ +
Public Portal (no auth)
+
+
🔗 /rfq/{token}
+
+ • Outside auth middleware — no login needed
+ • Token validated on every request
+ • Expires 7 days after sending
+ • Can only be submitted once
+ • Shows your company name + item list
+ • Supplier fills price/terms, submits
+ • Confirmation screen shown after submit +
+
+ +
+
Existing models — minor additions only
+
+ purchase_requests → add stage column
+ grn_items → add type (inventory|consumable)
+ purchase_orders → relabel as LPO in UI only +
+
+
+ +
+ +

Does this architecture look right? Reply in the terminal.

diff --git a/.superpowers/brainstorm/1542-1779106932/content/pipeline-style.html b/.superpowers/brainstorm/1542-1779106932/content/pipeline-style.html new file mode 100644 index 0000000..d6fa0ab --- /dev/null +++ b/.superpowers/brainstorm/1542-1779106932/content/pipeline-style.html @@ -0,0 +1,127 @@ +

How should the purchase pipeline tracker look?

+

Each purchase moves through stages like a delivery. Which style fits your team best?

+ +
+ + +
+
+
PO-2024-001 · Steel Pipes · BD 4,200
+
+ +
+
+
+
Request
+
+
+
+
+
GM Sign
+
+
+
+
+
Suppliers
+
+
+
+
4
+
Quotes ◄
+
+
+
+
5
+
LPO
+
+
+
+
6
+
Receive
+
+
+
+
7
+
Payment
+
+
+
+
+ ⏳ Waiting for supplier quotes — 2 of 3 received +
+
+
+

A — Horizontal Pipeline

+

Steps across the top like a progress bar. Current stage highlighted, done steps checked. Great for seeing the full journey at a glance on the list page.

+
+
+ + +
+
+
PO-2024-001 · Steel Pipes
+
+ +
+
+
+
Request Created
+
May 12 · Operations Team
+
+
+
+
GM Approved
+
May 13 · Ahmed Al-Rashid
+
+
+
+
Awaiting Quotes (2/3)
+
Sent to 3 suppliers · May 14
+
+
+
+
Quote Comparison
+
+
+
+
Issue LPO → Receive → Pay
+
+
+
+
+

B — Vertical Timeline

+

Like a courier tracking page — each step logged with date and actor. Best for the detail view of a single purchase. Shows history clearly.

+
+
+ + +
+
+
PO-2024-001 · Steel Pipes · BD 4,200
+ +
+
+
+
+
+
+
+
+
+
+ RequestGMRFQQuotes▲LPOReceivePay +
+
+ ⏳ Quotes: 2 of 3 received + Open → +
+
+
+

C — Combined (Recommended)

+

Compact coloured bar on the list page, full vertical timeline inside the detail view. Best of both worlds — scan many purchases quickly, then drill in for full history.

+
+
+ +
+ +

Click a card to select, then reply in the terminal.

diff --git a/.superpowers/brainstorm/1542-1779106932/content/waiting-1.html b/.superpowers/brainstorm/1542-1779106932/content/waiting-1.html new file mode 100644 index 0000000..a43d4fe --- /dev/null +++ b/.superpowers/brainstorm/1542-1779106932/content/waiting-1.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal…

+
diff --git a/.superpowers/brainstorm/1542-1779106932/content/waiting-2.html b/.superpowers/brainstorm/1542-1779106932/content/waiting-2.html new file mode 100644 index 0000000..68b29b8 --- /dev/null +++ b/.superpowers/brainstorm/1542-1779106932/content/waiting-2.html @@ -0,0 +1,3 @@ +
+

Writing the spec and implementation plan…

+
diff --git a/.superpowers/brainstorm/1542-1779106932/state/server-stopped b/.superpowers/brainstorm/1542-1779106932/state/server-stopped new file mode 100644 index 0000000..50fa9e6 --- /dev/null +++ b/.superpowers/brainstorm/1542-1779106932/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1779110714918} diff --git a/.superpowers/brainstorm/1542-1779106932/state/server.pid b/.superpowers/brainstorm/1542-1779106932/state/server.pid new file mode 100644 index 0000000..57c7c05 --- /dev/null +++ b/.superpowers/brainstorm/1542-1779106932/state/server.pid @@ -0,0 +1 @@ +1542 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d20edf7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,362 @@ +# OperationModule — SteelERP + +Manufacturing & Trading ERP. Laravel 12 / PHP 8.2. SQLite. Four modules: Purchase, Inventory, Production, Sales. + +--- + +## Quick Start + +```bash +composer install && npm install +cp .env.example .env && php artisan key:generate +php artisan migrate --seed +npm run dev # Vite dev server (keep running) +php artisan serve # http://localhost:8000 +``` + +--- + +## Tech Stack + +| | | +|---|---| +| Framework | Laravel 12, PHP 8.2 | +| Frontend | Tailwind CSS v3 (JIT), Alpine.js v3, Vite 7 | +| Database | SQLite — `database/database.sqlite` | +| Auth | Laravel Breeze (email+password, verified) | +| RBAC | spatie/laravel-permission v6 — middleware: `role`, `permission`, `role_or_permission` | +| PDF | barryvdh/laravel-dompdf v3 | +| Excel | phpoffice/phpspreadsheet v5 | +| Dev tools | Pint, Pail, Sail, PHPUnit 11 | + +**Roles (seeded):** Admin, Accounts, Store Manager, Production Manager, Sales Manager + +--- + +## Controllers — `app/Http/Controllers/` + +``` +Controller.php +DashboardController.php +ProfileController.php +Auth/ + AuthenticatedSessionController.php + ConfirmablePasswordController.php + EmailVerificationNotificationController.php + EmailVerificationPromptController.php + NewPasswordController.php + PasswordController.php + PasswordResetLinkController.php + RegisteredUserController.php + VerifyEmailController.php +Purchase/ + SupplierController.php import, downloadTemplate, exportPdf + CRUD + PurchaseRequestController.php + approve, reject, print + PurchaseOrderController.php + GoodsReceiptNoteController.php + confirm + SupplierInvoiceController.php + SupplierPaymentController.php +Inventory/ + ItemController.php import, downloadTemplate, exportPdf + CRUD + WarehouseController.php + StockMovementController.php + StockReportController.php summary, movement, lowStock, valuation +Production/ + ProductionOrderController.php + start, complete + BillOfMaterialController.php + MaterialIssueController.php + ProductionOutputController.php +Sales/ + CustomerController.php + SalesOrderController.php + confirm + DeliveryNoteController.php + dispatch + SalesInvoiceController.php + PaymentReceiptController.php +``` + +--- + +## Models — `app/Models/` + +``` +User.php +Supplier.php SupplierInvoice.php SupplierPayment.php +PurchaseRequest.php PurchaseRequestItem.php +PurchaseOrder.php PurchaseOrderItem.php +GoodsReceiptNote.php GrnItem.php +Item.php Warehouse.php StockLevel.php StockMovement.php +ProductionOrder.php BillOfMaterial.php MaterialIssue.php +ProductionOutput.php ProductionCost.php +Customer.php SalesOrder.php SalesOrderItem.php +DeliveryNote.php DeliveryNoteItem.php +SalesInvoice.php PaymentReceipt.php +``` + +--- + +## Services — `app/Services/` + +| File | Purpose | +|------|---------| +| `SupplierImportService.php` | Excel import — detects MRF vs template format, skips duplicates | +| `ItemImportService.php` | Excel import — detects Forkoll vs template format, skips duplicates | + +--- + +## Artisan Commands — `app/Console/Commands/` + +```bash +php artisan suppliers:import "path/to/file.xlsx" [--dry-run] +php artisan suppliers:template [--output=path] # → storage/app/suppliers_template.xlsx +php artisan items:template [--output=path] # → storage/app/items_template.xlsx +``` + +Files: `ImportSuppliers.php`, `GenerateSupplierTemplate.php`, `GenerateItemTemplate.php` + +--- + +## Routes — `routes/web.php` + +All protected by `['auth', 'verified']`. Prefix groups: + +### Purchase — `prefix('purchase')->name('purchase.')` +``` +GET/POST suppliers purchase.suppliers.* +POST suppliers/import purchase.suppliers.import ← must be BEFORE resource +GET suppliers/template purchase.suppliers.template ← must be BEFORE resource +GET suppliers/export-pdf purchase.suppliers.export-pdf ← must be BEFORE resource +GET/POST requests purchase.requests.* +PATCH requests/{id}/approve purchase.requests.approve +PATCH requests/{id}/reject purchase.requests.reject +GET requests/{id}/print purchase.requests.print +GET/POST orders purchase.orders.* +GET/POST grns purchase.grns.* +PATCH grns/{id}/confirm purchase.grns.confirm +GET/POST invoices purchase.invoices.* +GET/POST payments purchase.payments.* +``` + +### Inventory — `prefix('inventory')->name('inventory.')` +``` +GET/POST items inventory.items.* +POST items/import inventory.items.import ← must be BEFORE resource +GET items/template inventory.items.template ← must be BEFORE resource +GET items/export-pdf inventory.items.export-pdf ← must be BEFORE resource +GET/POST warehouses inventory.warehouses.* +GET/POST movements inventory.movements.* +GET reports/summary inventory.reports.summary +GET reports/movement inventory.reports.movement +GET reports/low-stock inventory.reports.low-stock +GET reports/valuation inventory.reports.valuation +``` + +### Production — `prefix('production')->name('production.')` +``` +GET/POST orders production.orders.* +PATCH orders/{id}/start production.orders.start +PATCH orders/{id}/complete production.orders.complete +GET/POST bom production.bom.* +GET/POST material-issues production.material-issues.* +GET/POST outputs production.outputs.* +``` + +### Sales — `prefix('sales')->name('sales.')` +``` +GET/POST customers sales.customers.* +GET/POST orders sales.orders.* +PATCH orders/{id}/confirm sales.orders.confirm +GET/POST delivery-notes sales.delivery-notes.* +PATCH delivery-notes/{id}/dispatch sales.delivery-notes.dispatch +GET/POST invoices sales.invoices.* +GET/POST payments sales.payments.* +``` + +--- + +## Views — `resources/views/` + +``` +dashboard.blade.php +welcome.blade.php +layouts/ + app.blade.php main layout (sidebar + topbar) + navigation.blade.php + guest.blade.php +components/ (Breeze defaults: modal, dropdown, buttons, inputs, etc.) +auth/ (login, register, forgot-password, reset-password, verify-email, confirm-password) +profile/edit.blade.php + partials/ + +purchase/ + suppliers/ index, create, edit, pdf + requests/ index, create, edit, show + orders/ index, create, edit, show + grns/ index, create, show + invoices/ index, create, edit + payments/ index, create + +inventory/ + items/ index, create, edit, pdf + warehouses/ index, create, edit + movements/ index, create + reports/ summary, movement, low-stock, valuation + +production/ + orders/ index, create, edit, show + bom/ index, create, edit + material-issues/ index + outputs/ index + +sales/ + customers/ index, create, edit + orders/ index, create, edit, show + delivery-notes/ index, create, edit + invoices/ index, create, edit + payments/ index, create +``` + +--- + +## Database — `database/` + +**Driver:** SQLite — `database/database.sqlite` + +### Migrations (29 total) +``` +users, cache, jobs (Laravel defaults) +permission_tables (Spatie) +suppliers, items, warehouses, stock_levels, stock_movements +purchase_orders, purchase_order_items, goods_receipt_notes, purchase_requests +grn_items, supplier_invoices, supplier_payments +production_orders, bill_of_materials, material_issues +production_outputs, production_costs +customers, sales_orders, sales_order_items, delivery_notes +delivery_note_items, sales_invoices, payment_receipts +purchase_request_items +``` + +### Seeders +`database/seeders/DatabaseSeeder.php` — creates roles (Admin, Accounts, Store Manager, Production Manager, Sales Manager) + +--- + +## MCP Server — `.claude/mcp-server.py` + +Read-only SQLite access. 16 tools: + +``` +list_tables describe_table(table) +run_query(sql) — SELECT/WITH only, max 200 rows + +get_suppliers(active_only) get_purchase_orders(status) +get_purchase_requests(status) get_supplier_invoices(status) + +get_items(category,active) get_stock_levels(warehouse_id,low_stock_only) +get_warehouses() + +get_production_orders(status) get_bill_of_materials(product_id) + +get_customers(active_only) get_sales_orders(status) +get_sales_invoices(status) get_dashboard_summary() +``` + +**Activation:** run `claude mcp add operation-module-db python -- .claude/mcp-server.py` once, then restart Claude Code. Use these tools before reading PHP files or running artisan for data questions. + +--- + +## Critical Gotchas + +### 1. Tailwind JIT — use inline styles for modals/dynamic classes +Vite JIT only compiles classes found in scanned source files at build time. Classes like `max-w-lg`, `max-w-md`, arbitrary colors used only in a modal or added dynamically **will not appear in the compiled CSS**. Always use `style="..."` inline for modal widths, custom colors, and one-off layout values. + +### 2. PhpSpreadsheet v5 — `getCellByColumnAndRow()` removed +Use `Coordinate::stringFromColumnIndex($col) . $row` then `$sheet->getCell($coord)` instead: +```php +use PhpOffice\PhpSpreadsheet\Cell\Coordinate; +$val = $sheet->getCell(Coordinate::stringFromColumnIndex($col) . $row)->getValue(); +``` + +### 3. Custom routes BEFORE Route::resource() +`Route::resource('suppliers', ...)` registers `{supplier}` wildcard that captures `import`, `template`, `export-pdf`. Custom routes MUST be declared first: +```php +Route::post('suppliers/import', ...); // first +Route::get('suppliers/template', ...); // first +Route::get('suppliers/export-pdf', ...); // first +Route::resource('suppliers', ...); // last +``` + +### 4. MRFMI supplier import — contact fields are always empty +The MRFMI price-comparison Excel file only contains supplier names in row 4 as column headers. There is no email, phone, or contact data anywhere in the file. This is expected — not a bug. + +### 5. Forkoll import — "DESCRIPTION" header row +The Forkoll inventory file has a header row (col A = SI.NO, col B = DESCRIPTION). `ItemImportService` skips col B values in `$headerWords = ['description', 'item name', 'item_name', 'material']` to avoid importing headers as items. + +### 6. Search bars — always client-side, never page-refreshing +All search/filter inputs MUST filter records instantly using JavaScript. No `?search=` URL params, no form submissions, no `debounce` + fetch, no page reloads of any kind. + +**The pattern to follow (used on the Suppliers index):** +```javascript +var searchInput = document.getElementById('my-search'); +var rows = document.querySelectorAll('.data-row'); +var countEl = document.getElementById('search-count'); +var total = rows.length; + +searchInput.addEventListener('input', function() { + var q = this.value.trim().toLowerCase(); + var visible = 0; + rows.forEach(function(row) { + var match = !q || row.textContent.toLowerCase().indexOf(q) !== -1; + row.classList.toggle('hidden-by-search', !match); + if (match) visible++; + }); + countEl.textContent = q ? (visible + ' of ' + total) : total; +}); +``` +- All records are loaded on page load — the server query returns everything, JS does the filtering +- Add `.hidden-by-search { display:none; }` to the page ` + + +
+
+
Quote Request
+
{{ $purchaseRequest->request_number }}
+
{{ $purchaseRequest->project_name }}
+
+ +
+

+ Hello {{ $invitation->supplier->name }}, please fill in your prices for the items below and submit your quote. This link can only be submitted once. +

+ +
+ @csrf + + +
+ + + + + + + + + + + + + @foreach($items as $i => $item) + + + + + + + + + @endforeach + + + + + + + +
#DescriptionQtyUnitUnit Price (BD)Total (BD)
{{ $i + 1 }}{{ $item->description }}{{ $item->quantity }}{{ $item->unit ?? '—' }} + +
Grand Total:BD 0.000
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+
+ + + + +``` + +- [ ] **Step 3: Thank-you view (`resources/views/rfq/submitted.blade.php`)** + +```blade + + + + + Quote Submitted + + + +
+
+
Quote Submitted
+
Thank you, {{ $invitation->supplier->name }}. Your quote for {{ $invitation->purchaseRequest->request_number }} has been received. You will be contacted if you are selected.
+
+ + +``` + +- [ ] **Step 4: Expired view (`resources/views/rfq/expired.blade.php`)** + +```blade + + + + + Link Expired + + + +
+
+
Link Expired
+
This quote invitation link has expired. Please contact the purchasing team if you still wish to submit a quote.
+
+ + +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/Http/Controllers/Purchase/RfqPortalController.php resources/views/rfq/ +git commit -m "feat: public supplier quote portal — form, submitted, expired views" +``` + +--- + +## Task 9: SupplierQuoteController — view quotes + compare + award + +**Files:** +- Create: `app/Http/Controllers/Purchase/SupplierQuoteController.php` +- Create: `resources/views/purchase/quotes/index.blade.php` +- Create: `resources/views/purchase/quotes/compare.blade.php` + +- [ ] **Step 1: Write SupplierQuoteController** + +```php +supplierQuotes()->with('supplier', 'items')->get(); + return view('purchase.quotes.index', compact('request', 'quotes')); + } + + public function compare(PurchaseRequest $request) + { + $quotes = $request->supplierQuotes()->with('supplier', 'items')->get(); + $items = $request->items; + return view('purchase.quotes.compare', compact('request', 'quotes', 'items')); + } + + public function award(Request $request, PurchaseRequest $purchaseRequest, SupplierQuote $quote, PurchaseStageService $stages) + { + $validated = $request->validate([ + 'award_reason' => ['required', 'string', 'min:5'], + ]); + + if ($purchaseRequest->awardedQuote) { + return back()->with('error', 'A quote has already been awarded for this request.'); + } + + // Mark all other quotes as not awarded (they remain in the table) + $purchaseRequest->supplierQuotes()->where('id', '!=', $quote->id)->update(['is_awarded' => false]); + + $quote->update([ + 'is_awarded' => true, + 'award_reason' => $validated['award_reason'], + 'awarded_at' => now(), + 'awarded_by' => auth()->id(), + ]); + + $stages->setStage($purchaseRequest, 'lpo'); + + return redirect()->route('purchase.pipeline.index') + ->with('success', "Quote from {$quote->supplier->name} awarded. Ready to issue LPO."); + } +} +``` + +- [ ] **Step 2: Write quotes index view** + +```blade +@extends('layouts.app') + +@section('content') +
+
+ +
+
+
Supplier Quotes
+
{{ $request->request_number }}
+
+ @if($quotes->count() >= 1) + + Compare → + + @endif +
+ +
+ @if($quotes->isEmpty()) +

No quotes received yet.

+ @else +
+ @foreach($quotes as $quote) +
+
+
{{ $quote->supplier->name }}
+
BD {{ number_format($quote->total_amount, 3) }}
+
+
+ Lead time: {{ $quote->lead_time_days ? $quote->lead_time_days.' days' : '—' }} · + Terms: {{ $quote->payment_terms ?: '—' }} · + Submitted: {{ $quote->submitted_at->format('d M Y') }} +
+ @if($quote->is_awarded) +
✓ Awarded
+ @endif +
+ @endforeach +
+ @endif +
+
+
+@endsection +``` + +- [ ] **Step 3: Write quote comparison view** + +```blade +@extends('layouts.app') + +@section('content') +
+
+ +
+
Quote Comparison
+
{{ $request->request_number }}
+
+ +
+ @if($quotes->isEmpty()) +

No quotes to compare.

+ @else + + + + + @foreach($quotes as $quote) + + @endforeach + + + + {{-- Items rows --}} + @foreach($items as $i => $reqItem) + + + @php + $rowPrices = $quotes->map(fn($q) => optional($q->items->get($i))->unit_price ?? null)->filter(); + $minPrice = $rowPrices->min(); + @endphp + @foreach($quotes as $q) + @php $qItem = $q->items->get($i); @endphp + + @endforeach + + @endforeach + + {{-- Totals row --}} + + + @php $minTotal = $quotes->min('total_amount'); @endphp + @foreach($quotes as $quote) + + @endforeach + + + {{-- Lead time --}} + + + @foreach($quotes as $quote) + + @endforeach + + + {{-- Payment terms --}} + + + @foreach($quotes as $quote) + + @endforeach + + + {{-- Award buttons --}} + @if(!$request->awardedQuote) + + + @foreach($quotes as $quote) + + @endforeach + + @else + + + + @endif + +
Item + {{ $quote->supplier->name }}
+ {{ $quote->submitted_at->format('d M') }} +
+ {{ $reqItem->description }}
+ Qty: {{ $reqItem->quantity }} +
+ @if($qItem) +
+ BD {{ number_format($qItem->unit_price, 3) }} +
+
Total: BD {{ number_format($qItem->total_price, 3) }}
+ @else + + @endif +
Grand Total + BD {{ number_format($quote->total_amount, 3) }} +
Lead Time + {{ $quote->lead_time_days ? $quote->lead_time_days.' days' : '—' }} +
Payment Terms + {{ $quote->payment_terms ?: '—' }} +
Award to: + +
+ ✓ Quote awarded to {{ $request->awardedQuote->supplier->name }} +
+ @endif +
+
+
+ + + + + +@endsection +``` + +- [ ] **Step 4: Commit** + +```bash +git add app/Http/Controllers/Purchase/SupplierQuoteController.php resources/views/purchase/quotes/ +git commit -m "feat: supplier quote controller — index, compare, award views" +``` + +--- + +## Task 10: Pipeline index view with vertical timeline + +**Files:** +- Create: `app/Http/Controllers/Purchase/PurchasePipelineController.php` +- Create: `resources/views/purchase/pipeline/index.blade.php` + +- [ ] **Step 1: Write PurchasePipelineController** + +```php +orderByRaw("CASE stage + WHEN 'draft' THEN 0 WHEN 'gm_approval' THEN 1 WHEN 'rfq' THEN 2 + WHEN 'quoting' THEN 3 WHEN 'comparison' THEN 4 WHEN 'lpo' THEN 5 + WHEN 'receiving' THEN 6 WHEN 'payment' THEN 7 WHEN 'complete' THEN 8 + ELSE 9 END") + ->latest()->get(); + + return view('purchase.pipeline.index', compact('requests', 'stages')); + } +} +``` + +- [ ] **Step 2: Write the pipeline index view** + +```blade +@extends('layouts.app') + +@section('content') +
+ +
+
+

Purchase Pipeline

+

Track every purchase from request to payment

+
+ + + New Request + +
+ + @if($requests->isEmpty()) +
+
📋
+
No purchases yet
+
Create a purchase request to get started.
+
+ @endif + +
+ @foreach($requests as $pr) + @php + $stageIdx = $stages->stageIndex($pr->stage); + $allStages = \App\Services\PurchaseStageService::STAGES; + @endphp +
+ + +
+
+
{{ $pr->request_number }}
+
{{ $pr->project_name }} · {{ $pr->date->format('d M Y') }}
+
+
+ {{ $stages->stageLabel($pr->stage) }} +
+
+ + +
+
+
+ + +
+
+ + @foreach($allStages as $i => $stage) + @php + $done = $i < $stageIdx; + $current = $i === $stageIdx; + $future = $i > $stageIdx; + @endphp +
+ +
+
+ @if($done) + + @endif +
+ @if($i < count($allStages) - 1) +
+ @endif +
+ + +
+
+ + {{ $stages->stageLabel($stage) }} + + + {{-- Action button for current stage --}} + @if($current) + @if($stage === 'gm_approval') + + Sign → + + @elseif($stage === 'rfq') + + Select Suppliers → + + @elseif($stage === 'quoting') + + View Quotes ({{ $pr->supplierQuotes->count() }}) → + + @elseif($stage === 'comparison') + + Compare & Award → + + @elseif($stage === 'lpo') + + Issue LPO → + + @elseif($stage === 'receiving') + + Record GRN → + + @elseif($stage === 'payment') + + Issue Payment → + + @endif + @endif +
+ + {{-- Stage detail text --}} + @if($done || $current) +
+ @if($stage === 'draft') Created by {{ $pr->requestedBy->name }} · {{ $pr->created_at->format('d M Y') }} + @elseif($stage === 'gm_approval' && $pr->signature) Signed by {{ $pr->signature->signedBy->name }} · {{ $pr->signature->signed_at->format('d M Y') }} + @elseif($stage === 'rfq' || $stage === 'quoting') {{ $pr->rfqInvitations->count() }} supplier(s) invited + @elseif($stage === 'comparison') {{ $pr->supplierQuotes->count() }} quote(s) received + @elseif($stage === 'lpo' && $pr->awardedQuote) Awarded to {{ $pr->awardedQuote->supplier->name }} + @endif +
+ @endif +
+
+ @endforeach + +
+
+
+ @endforeach +
+
+@endsection +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/Http/Controllers/Purchase/PurchasePipelineController.php resources/views/purchase/pipeline/ +git commit -m "feat: purchase pipeline index — card list with vertical timeline" +``` + +--- + +## Task 11: Route wiring + sidebar navigation link + +**Files:** +- Modify: `routes/web.php` +- Modify: `resources/views/layouts/app.blade.php` + +- [ ] **Step 1: Add all new routes to `routes/web.php`** + +Add these imports at the top of `web.php` (after existing Purchase imports): + +```php +use App\Http\Controllers\Purchase\PurchasePipelineController; +use App\Http\Controllers\Purchase\PurchaseSignatureController; +use App\Http\Controllers\Purchase\RfqController; +use App\Http\Controllers\Purchase\SupplierQuoteController; +use App\Http\Controllers\Purchase\RfqPortalController; +``` + +Add the public RFQ route **before** the auth middleware group: + +```php +// Public — no auth required +Route::get('/rfq/{token}', [RfqPortalController::class, 'show'])->name('rfq.show'); +Route::post('/rfq/{token}', [RfqPortalController::class, 'submit'])->name('rfq.submit'); +``` + +Inside the `purchase.` prefix group, add: + +```php +Route::get('pipeline', [PurchasePipelineController::class, 'index'])->name('pipeline.index'); +Route::get('requests/{request}/sign', [PurchaseSignatureController::class, 'show'])->name('requests.sign'); +Route::post('requests/{request}/sign', [PurchaseSignatureController::class, 'store'])->name('requests.sign.store'); +Route::get('requests/{request}/rfq', [RfqController::class, 'show'])->name('requests.rfq'); +Route::post('requests/{request}/rfq', [RfqController::class, 'store'])->name('requests.rfq.store'); +Route::get('requests/{request}/quotes', [SupplierQuoteController::class, 'index'])->name('requests.quotes'); +Route::get('requests/{request}/compare', [SupplierQuoteController::class, 'compare'])->name('requests.compare'); +Route::post('requests/{request}/quotes/{quote}/award', [SupplierQuoteController::class, 'award'])->name('requests.quotes.award'); +``` + +- [ ] **Step 2: Add sidebar link to the layout** + +Open `resources/views/layouts/app.blade.php`. Find where the sidebar purchase links are. Add a "Pipeline" link as the first item under Purchase: + +```blade + + Pipeline + +``` + +(Match the exact element tag/class used by the rest of the sidebar navigation links in the file.) + +- [ ] **Step 3: Verify routes load** + +``` +php artisan route:list --path=purchase/pipeline +php artisan route:list --path=rfq +``` + +Expected: `purchase.pipeline.index`, `rfq.show`, `rfq.submit` listed. + +- [ ] **Step 4: Commit** + +```bash +git add routes/web.php resources/views/layouts/app.blade.php +git commit -m "feat: wire pipeline routes and sidebar navigation" +``` + +--- + +## Task 12: Seed existing purchase requests with `draft` stage + +Existing `purchase_requests` rows will have a NULL `stage` (migration sets default `draft` for new rows but existing NULL rows won't match the pipeline logic). + +**Files:** +- Create: `database/seeders/PurchaseRequestStagePatchSeeder.php` + +- [ ] **Step 1: Write the one-time patch seeder** + +```php +where('status', 'approved')->whereNull('stage')->update(['stage' => 'rfq']); + DB::table('purchase_requests')->where('status', 'pending')->whereNull('stage')->update(['stage' => 'gm_approval']); + DB::table('purchase_requests')->whereNull('stage')->update(['stage' => 'draft']); + } +} +``` + +- [ ] **Step 2: Run the seeder** + +``` +php artisan db:seed --class=PurchaseRequestStagePatchSeeder +``` + +Expected: No errors; existing rows now have non-null stage values. + +- [ ] **Step 3: Commit** + +```bash +git add database/seeders/PurchaseRequestStagePatchSeeder.php +git commit -m "feat: patch existing purchase_requests with initial stage values" +``` + +--- + +## Task 13: GRN receiving view — toggle Inventory / Consumable per item + +**Files:** +- Modify: `resources/views/purchase/grns/create.blade.php` + +This is a targeted edit to the existing GRN creation form. Find the item rows section and add a toggle after the quantity/cost fields for each item. + +- [ ] **Step 1: Read the existing GRN create view** to understand how item rows are rendered before editing. + +``` +Read: resources/views/purchase/grns/create.blade.php +``` + +- [ ] **Step 2: For each item row in the form, add a type toggle** + +After the existing quantity / unit cost inputs for each GRN item, add: + +```blade +
+ + +
+``` + +And a small JS helper (once in the view, not per row): + +```javascript +function setGrnType(span, val) { + const parent = span.closest('label').parentElement; + parent.querySelectorAll('.grn-type-btn').forEach(b => { + b.style.background = '#fff'; b.style.color = '#64748b'; + }); + span.style.background = val === 'inventory' ? '#eff6ff' : '#fef3c7'; + span.style.color = val === 'inventory' ? '#2563eb' : '#92400e'; +} +``` + +- [ ] **Step 3: Update GoodsReceiptNoteController to accept and store the `type` field** + +In `GoodsReceiptNoteController::store()`, when creating each `GrnItem`, pass `type` from the request: + +```php +$item->type = $itemData['type'] ?? 'inventory'; +$item->save(); +``` + +(Read the exact controller code first to insert at the right location.) + +- [ ] **Step 4: Commit** + +```bash +git add resources/views/purchase/grns/create.blade.php app/Http/Controllers/Purchase/GoodsReceiptNoteController.php +git commit -m "feat: GRN item type toggle — inventory or consumable" +``` + +--- + +## Self-Review Checklist + +**Spec coverage:** +- [x] Stage 1 (Purchase Request) — existing, no new code needed; stage seeded via Task 12 +- [x] Stage 2 (GM Signature) — Task 6 +- [x] Stage 3 (Select Suppliers + RFQ) — Task 7 +- [x] Stage 4 (Supplier Quotes via private link) — Task 8 +- [x] Stage 5 (Comparison + Award) — Task 9 +- [x] Stage 6 (LPO) — action button links to existing `purchase.orders.create`; no new controller needed +- [x] Stage 7 (Receiving with inventory/consumable) — Task 13 +- [x] Stage 8 (Payment) — action button links to existing `purchase.payments.create`; no new controller needed +- [x] Pipeline index view — Task 10 +- [x] Stage advancement via `PurchaseStageService` — Task 5 +- [x] Token expiry, one-submit-only — `RfqPortalController::show()` checks both +- [x] Awarding is irreversible / locks comparison — `SupplierQuoteController::award()` guard +- [x] WhatsApp deep link — `RfqInvitationService::whatsappLink()` +- [x] Email send — `RfqInvitationMail` + `rfq-invitation.blade.php` +- [x] Public route outside auth — Task 11 adds routes before middleware group +- [x] `grn_items.type` field — Task 2 + Task 13 + +**Known dependency:** Stage 6 (LPO) and Stage 8 (Payment) action buttons link to existing controllers. Those controllers don't advance the pipeline stage automatically. A follow-up task should add `$stages->setStage($pr, 'receiving')` after LPO creation, and `$stages->setStage($pr, 'complete')` after payment creation. This can be done as a polish pass without blocking the core pipeline. diff --git a/docs/superpowers/plans/2026-05-19-ultra-message-package.md b/docs/superpowers/plans/2026-05-19-ultra-message-package.md new file mode 100644 index 0000000..8f40530 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-ultra-message-package.md @@ -0,0 +1,2380 @@ +# Ultra Message Package — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `promoseven/ultra-message`, a reusable Laravel package that wraps the UltraMSG WhatsApp API, then integrate it into OperationModule with a database-backed Settings UI and per-event notification classes. + +**Architecture:** Three phases. Phase 1 builds the standalone Composer package (separate directory/repo). Phase 2 installs it into OperationModule and wires up the Settings UI + dynamic config. Phase 3 wires individual Laravel Notification classes to ERP events. + +**Tech Stack:** PHP 8.2, Laravel 12, UltraMSG REST API, Orchestra Testbench 10 (package tests), PHPUnit 11, SQLite (OperationModule DB), Tailwind CSS + Alpine.js (Settings UI) + +--- + +## File Map + +### Phase 1 — Package (new directory: `../ultra-message/` — sibling to OperationModule) + +| Action | Path | Responsibility | +|--------|------|----------------| +| Create | `composer.json` | Package metadata, autoloading, Laravel discovery | +| Create | `config/ultra-message.php` | Default config values | +| Create | `src/UltraMessageServiceProvider.php` | Register client singleton, load config/routes | +| Create | `src/UltraMessageClient.php` | All HTTP calls to UltraMSG API | +| Create | `src/UltraMessageMessage.php` | Fluent DTO for outbound messages | +| Create | `src/UltraMessageChannel.php` | Laravel Notification channel | +| Create | `src/UltraMessageFake.php` | Test double — records sends, no HTTP | +| Create | `src/UltraMessageException.php` | Single exception type for all API errors | +| Create | `src/Facades/UltraMessage.php` | Laravel Facade → UltraMessageClient | +| Create | `src/Events/UltraMessageWebhookReceived.php` | Event fired on incoming webhook | +| Create | `src/Http/Controllers/WebhookController.php` | Handles POST to webhook route | +| Create | `routes/webhook.php` | Registers webhook POST route | +| Create | `tests/UltraMessageClientTest.php` | Unit tests for the client | +| Create | `tests/UltraMessageChannelTest.php` | Unit tests for the channel | +| Create | `tests/TestCase.php` | Orchestra Testbench base test case | + +### Phase 2 — OperationModule Integration + +| Action | Path | Responsibility | +|--------|------|----------------| +| Modify | `composer.json` | Add VCS repository + require package | +| Create | `database/migrations/xxxx_create_settings_table.php` | `settings` key/value table | +| Create | `database/migrations/xxxx_add_whatsapp_number_to_suppliers.php` | `whatsapp_number` column | +| Create | `database/migrations/xxxx_add_whatsapp_number_to_customers.php` | `whatsapp_number` column | +| Create | `database/migrations/xxxx_add_whatsapp_number_to_users.php` | `whatsapp_number` column | +| Create | `app/Models/Setting.php` | Key/value model with static get/set helpers | +| Modify | `app/Models/Supplier.php` | Add `routeNotificationFor('ultra_message')` | +| Modify | `app/Models/Customer.php` | Add `routeNotificationFor('ultra_message')` | +| Modify | `app/Models/User.php` | Add `routeNotificationFor('ultra_message')` | +| Modify | `app/Providers/AppServiceProvider.php` | Boot dynamic config resolver | +| Modify | `bootstrap/app.php` | Exclude webhook path from CSRF | +| Create | `app/Http/Controllers/SettingsController.php` | Show/update integration settings | +| Create | `resources/views/settings/integrations.blade.php` | WhatsApp settings form | +| Modify | `routes/web.php` | Add settings routes | +| Modify | `resources/views/layouts/navigation.blade.php` | Add Settings sidebar link (Admin only) | +| Modify | `resources/views/purchase/suppliers/create.blade.php` | Add whatsapp_number field | +| Modify | `resources/views/purchase/suppliers/edit.blade.php` | Add whatsapp_number field | +| Modify | `resources/views/sales/customers/create.blade.php` | Add whatsapp_number field | +| Modify | `resources/views/sales/customers/edit.blade.php` | Add whatsapp_number field | +| Modify | `app/Http/Controllers/Purchase/SupplierController.php` | Store/update whatsapp_number | +| Modify | `app/Http/Controllers/Sales/CustomerController.php` | Store/update whatsapp_number | + +### Phase 3 — Notifications + +| Action | Path | Responsibility | +|--------|------|----------------| +| Create | `app/Notifications/Purchase/PurchaseOrderConfirmedNotification.php` | Notify supplier on PO confirm | +| Create | `app/Notifications/Purchase/GoodsReceiptConfirmedNotification.php` | Notify store manager on GRN confirm | +| Create | `app/Notifications/Sales/SalesOrderConfirmedNotification.php` | Notify customer on SO confirm | +| Create | `app/Notifications/Sales/InvoiceCreatedNotification.php` | Notify customer on invoice creation | +| Create | `app/Notifications/Sales/DeliveryDispatchedNotification.php` | Notify customer on dispatch | +| Create | `app/Notifications/Inventory/LowStockAlertNotification.php` | Notify store manager on low stock | +| Create | `app/Notifications/Production/ProductionOrderCompletedNotification.php` | Notify production manager on completion | +| Modify | `app/Http/Controllers/Purchase/PurchaseOrderController.php` | Trigger PO confirmed notification | +| Modify | `app/Http/Controllers/Purchase/GoodsReceiptNoteController.php` | Trigger GRN confirmed notification | +| Modify | `app/Http/Controllers/Sales/SalesOrderController.php` | Trigger SO confirmed notification | +| Modify | `app/Http/Controllers/Sales/SalesInvoiceController.php` | Trigger invoice created notification | +| Modify | `app/Http/Controllers/Sales/DeliveryNoteController.php` | Trigger dispatch notification | +| Modify | `app/Http/Controllers/Inventory/StockMovementController.php` | Trigger low stock check + notification | +| Modify | `app/Http/Controllers/Production/ProductionOrderController.php` | Trigger production complete notification | + +--- + +## PHASE 1 — The Package + +> Work in a new directory: create `ultra-message/` as a sibling to `OperationModule/` (e.g. `C:\Users\IT Department\Desktop\ultra-message\`). + +--- + +### Task 1: Package scaffold — composer.json and directory structure + +**Files:** +- Create: `ultra-message/composer.json` +- Create: `ultra-message/src/` (empty dir placeholder) +- Create: `ultra-message/config/ultra-message.php` +- Create: `ultra-message/routes/webhook.php` +- Create: `ultra-message/tests/TestCase.php` + +- [ ] **Step 1: Create the package directory and composer.json** + +```bash +mkdir -p ultra-message/src/Facades ultra-message/src/Events ultra-message/src/Http/Controllers ultra-message/config ultra-message/routes ultra-message/tests +``` + +Create `ultra-message/composer.json`: + +```json +{ + "name": "promoseven/ultra-message", + "description": "Laravel WhatsApp integration via UltraMSG API", + "type": "library", + "license": "MIT", + "require": { + "php": "^8.2", + "illuminate/support": "^11.0|^12.0", + "illuminate/http": "^11.0|^12.0", + "illuminate/notifications": "^11.0|^12.0", + "illuminate/routing": "^11.0|^12.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "orchestra/testbench": "^10.0" + }, + "autoload": { + "psr-4": { + "PromoSeven\\UltraMessage\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PromoSeven\\UltraMessage\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "PromoSeven\\UltraMessage\\UltraMessageServiceProvider" + ], + "aliases": { + "UltraMessage": "PromoSeven\\UltraMessage\\Facades\\UltraMessage" + } + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} +``` + +- [ ] **Step 2: Create the default config file** + +Create `ultra-message/config/ultra-message.php`: + +```php + env('ULTRAMSG_INSTANCE_ID'), + 'token' => env('ULTRAMSG_TOKEN'), + 'webhook_secret' => env('ULTRAMSG_WEBHOOK_SECRET', null), + 'webhook_path' => env('ULTRAMSG_WEBHOOK_PATH', 'ultra-message/webhook'), + 'timeout' => env('ULTRAMSG_TIMEOUT', 30), + 'enabled' => env('ULTRAMSG_ENABLED', true), +]; +``` + +- [ ] **Step 3: Create the Orchestra Testbench base test case** + +Create `ultra-message/tests/TestCase.php`: + +```php +set('ultra-message.instance_id', 'instance123'); + $app['config']->set('ultra-message.token', 'test-token'); + $app['config']->set('ultra-message.enabled', true); + } +} +``` + +- [ ] **Step 4: Run composer install** + +```bash +cd ultra-message && composer install +``` + +Expected: Vendor directory created, no errors. + +- [ ] **Step 5: Commit** + +```bash +cd ultra-message +git init +git add . +git commit -m "feat: scaffold ultra-message package" +``` + +--- + +### Task 2: UltraMessageException + +**Files:** +- Create: `ultra-message/src/UltraMessageException.php` + +- [ ] **Step 1: Create the exception class** + +Create `ultra-message/src/UltraMessageException.php`: + +```php +client = new UltraMessageClient([ + 'instance_id' => 'instance123', + 'token' => 'test-token', + 'timeout' => 30, + 'enabled' => true, + ]); + } + + public function test_send_text_posts_correct_payload(): void + { + Http::fake([ + 'api.ultramsg.com/*' => Http::response(['sent' => 'true', 'id' => 'msg1'], 200), + ]); + + $result = $this->client->sendText('+971501234567', 'Hello World'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'instance123/messages/chat') + && $request['to'] === '+971501234567' + && $request['body'] === 'Hello World' + && $request['token'] === 'test-token'; + }); + + $this->assertEquals(['sent' => 'true', 'id' => 'msg1'], $result); + } + + public function test_send_text_throws_on_api_error(): void + { + Http::fake([ + 'api.ultramsg.com/*' => Http::response(['error' => 'invalid token'], 200), + ]); + + $this->expectException(UltraMessageException::class); + $this->expectExceptionMessage('invalid token'); + + $this->client->sendText('+971501234567', 'Hello'); + } + + public function test_send_text_throws_on_http_failure(): void + { + Http::fake([ + 'api.ultramsg.com/*' => Http::response([], 500), + ]); + + $this->expectException(UltraMessageException::class); + + $this->client->sendText('+971501234567', 'Hello'); + } + + public function test_send_returns_early_when_disabled(): void + { + Http::fake(); + + $client = new UltraMessageClient([ + 'instance_id' => 'instance123', + 'token' => 'test-token', + 'timeout' => 30, + 'enabled' => false, + ]); + + $result = $client->sendText('+971501234567', 'Hello'); + + Http::assertNothingSent(); + $this->assertEquals([], $result); + } + + public function test_send_image_posts_correct_payload(): void + { + Http::fake([ + 'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200), + ]); + + $this->client->sendImage('+971501234567', 'https://example.com/img.jpg', 'Caption'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'instance123/messages/image') + && $request['image'] === 'https://example.com/img.jpg' + && $request['caption'] === 'Caption'; + }); + } + + public function test_send_document_posts_correct_payload(): void + { + Http::fake([ + 'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200), + ]); + + $this->client->sendDocument('+971501234567', 'https://example.com/file.pdf', 'invoice.pdf', 'Your invoice'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'instance123/messages/document') + && $request['document'] === 'https://example.com/file.pdf' + && $request['filename'] === 'invoice.pdf' + && $request['caption'] === 'Your invoice'; + }); + } + + public function test_send_location_posts_correct_payload(): void + { + Http::fake([ + 'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200), + ]); + + $this->client->sendLocation('+971501234567', 25.197197, 55.2721877, 'Dubai, UAE'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'instance123/messages/location') + && $request['lat'] == 25.197197 + && $request['lng'] == 55.2721877 + && $request['address'] === 'Dubai, UAE'; + }); + } +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cd ultra-message && ./vendor/bin/phpunit tests/UltraMessageClientTest.php +``` + +Expected: ERRORS — `UltraMessageClient` class not found. + +- [ ] **Step 3: Implement UltraMessageClient** + +Create `ultra-message/src/UltraMessageClient.php`: + +```php +instanceId = $config['instance_id'] ?? ''; + $this->token = $config['token'] ?? ''; + $this->timeout = $config['timeout'] ?? 30; + $this->enabled = $config['enabled'] ?? true; + } + + protected function post(string $endpoint, array $data): array + { + if (!$this->enabled) { + return []; + } + + $response = Http::timeout($this->timeout) + ->asForm() + ->post(self::BASE_URL . "/{$this->instanceId}/{$endpoint}", array_merge($data, [ + 'token' => $this->token, + ])); + + if ($response->failed()) { + throw new UltraMessageException("UltraMSG HTTP error: {$response->status()}"); + } + + $body = $response->json() ?? []; + + if (isset($body['error'])) { + throw new UltraMessageException($body['error']); + } + + return $body; + } + + protected function get(string $endpoint, array $query = []): array + { + if (!$this->enabled) { + return []; + } + + $response = Http::timeout($this->timeout) + ->get(self::BASE_URL . "/{$this->instanceId}/{$endpoint}", array_merge($query, [ + 'token' => $this->token, + ])); + + if ($response->failed()) { + throw new UltraMessageException("UltraMSG HTTP error: {$response->status()}"); + } + + $body = $response->json() ?? []; + + if (isset($body['error'])) { + throw new UltraMessageException($body['error']); + } + + return $body; + } + + public function sendText(string $to, string $message, ?string $replyId = null): array + { + $data = ['to' => $to, 'body' => $message]; + if ($replyId !== null) { + $data['quoted_id'] = $replyId; + } + return $this->post('messages/chat', $data); + } + + public function sendImage(string $to, string $imageUrl, string $caption = ''): array + { + return $this->post('messages/image', [ + 'to' => $to, + 'image' => $imageUrl, + 'caption' => $caption, + ]); + } + + public function sendDocument(string $to, string $fileUrl, string $filename, string $caption = ''): array + { + return $this->post('messages/document', [ + 'to' => $to, + 'document' => $fileUrl, + 'filename' => $filename, + 'caption' => $caption, + ]); + } + + public function sendAudio(string $to, string $audioUrl): array + { + return $this->post('messages/audio', [ + 'to' => $to, + 'audio' => $audioUrl, + ]); + } + + public function sendVoice(string $to, string $audioUrl): array + { + return $this->post('messages/voice', [ + 'to' => $to, + 'audio' => $audioUrl, + ]); + } + + public function sendVideo(string $to, string $videoUrl, string $caption = ''): array + { + return $this->post('messages/video', [ + 'to' => $to, + 'video' => $videoUrl, + 'caption' => $caption, + ]); + } + + public function sendSticker(string $to, string $stickerUrl): array + { + return $this->post('messages/sticker', [ + 'to' => $to, + 'sticker' => $stickerUrl, + ]); + } + + public function sendContact(string $to, string $contactId): array + { + return $this->post('messages/contact', [ + 'to' => $to, + 'contact' => $contactId, + ]); + } + + public function sendLocation(string $to, float $lat, float $lng, string $address = ''): array + { + return $this->post('messages/location', [ + 'to' => $to, + 'lat' => $lat, + 'lng' => $lng, + 'address' => $address, + ]); + } + + public function sendReaction(string $to, string $messageId, string $emoji): array + { + return $this->post('messages/reaction', [ + 'to' => $to, + 'msgId' => $messageId, + 'emoji' => $emoji, + ]); + } + + public function deleteMessage(string $messageId): array + { + return $this->post('messages/delete', [ + 'msgId' => $messageId, + ]); + } + + public function getInstanceStatus(): array + { + return $this->get('instance/status'); + } + + public function getChats(): array + { + return $this->get('chats/'); + } + + public function getContacts(): array + { + return $this->get('contacts/'); + } + + public function getGroups(): array + { + return $this->get('groups/'); + } +} +``` + +- [ ] **Step 4: Run tests — confirm they pass** + +```bash +./vendor/bin/phpunit tests/UltraMessageClientTest.php +``` + +Expected: 5 tests, 5 assertions, PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/UltraMessageClient.php tests/UltraMessageClientTest.php +git commit -m "feat: implement UltraMessageClient with all send methods" +``` + +--- + +### Task 4: UltraMessageMessage DTO + +**Files:** +- Create: `ultra-message/src/UltraMessageMessage.php` + +- [ ] **Step 1: Create the DTO** + +Create `ultra-message/src/UltraMessageMessage.php`: + +```php +type = $type; + $this->payload = $payload; + } + + public static function text(string $message, ?string $replyId = null): self + { + return new self('text', ['body' => $message, 'quoted_id' => $replyId]); + } + + public static function image(string $url, string $caption = ''): self + { + return new self('image', ['image' => $url, 'caption' => $caption]); + } + + public static function document(string $url, string $filename, string $caption = ''): self + { + return new self('document', ['document' => $url, 'filename' => $filename, 'caption' => $caption]); + } + + public static function audio(string $url): self + { + return new self('audio', ['audio' => $url]); + } + + public static function voice(string $url): self + { + return new self('voice', ['audio' => $url]); + } + + public static function video(string $url, string $caption = ''): self + { + return new self('video', ['video' => $url, 'caption' => $caption]); + } + + public static function sticker(string $url): self + { + return new self('sticker', ['sticker' => $url]); + } + + public static function contact(string $contactId): self + { + return new self('contact', ['contact' => $contactId]); + } + + public static function location(float $lat, float $lng, string $address = ''): self + { + return new self('location', ['lat' => $lat, 'lng' => $lng, 'address' => $address]); + } + + public function to(string $number): self + { + $this->to = $number; + return $this; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/UltraMessageMessage.php +git commit -m "feat: add UltraMessageMessage DTO" +``` + +--- + +### Task 5: UltraMessageChannel + +**Files:** +- Create: `ultra-message/src/UltraMessageChannel.php` +- Create: `ultra-message/tests/UltraMessageChannelTest.php` + +- [ ] **Step 1: Write the failing tests** + +Create `ultra-message/tests/UltraMessageChannelTest.php`: + +```php + Http::response(['sent' => 'true'], 200)]); + + $client = new UltraMessageClient([ + 'instance_id' => 'instance123', + 'token' => 'test-token', + 'timeout' => 30, + 'enabled' => true, + ]); + $channel = new UltraMessageChannel($client); + + $notifiable = new class { + public string $whatsapp_number = '+971501234567'; + public function routeNotificationFor(string $channel, $notification = null): string + { + return $this->whatsapp_number; + } + }; + + $notification = new class extends Notification { + public function toUltraMessage($notifiable): UltraMessageMessage + { + return UltraMessageMessage::text('Test message'); + } + }; + + $channel->send($notifiable, $notification); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'messages/chat') + && $request['body'] === 'Test message' + && $request['to'] === '+971501234567'; + }); + } + + public function test_channel_uses_message_to_over_notifiable_route(): void + { + Http::fake(['api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200)]); + + $client = new UltraMessageClient([ + 'instance_id' => 'instance123', + 'token' => 'test-token', + 'timeout' => 30, + 'enabled' => true, + ]); + $channel = new UltraMessageChannel($client); + + $notifiable = new class { + public function routeNotificationFor(string $channel, $notification = null): string + { + return '+9710000000'; + } + }; + + $notification = new class extends Notification { + public function toUltraMessage($notifiable): UltraMessageMessage + { + return UltraMessageMessage::text('Override test')->to('+971999999'); + } + }; + + $channel->send($notifiable, $notification); + + Http::assertSent(fn($r) => $r['to'] === '+971999999'); + } + + public function test_channel_skips_when_no_recipient(): void + { + Http::fake(); + + $client = new UltraMessageClient([ + 'instance_id' => 'instance123', + 'token' => 'test-token', + 'timeout' => 30, + 'enabled' => true, + ]); + $channel = new UltraMessageChannel($client); + + $notifiable = new class { + public function routeNotificationFor(string $channel, $notification = null): ?string + { + return null; + } + }; + + $notification = new class extends Notification { + public function toUltraMessage($notifiable): UltraMessageMessage + { + return UltraMessageMessage::text('No recipient'); + } + }; + + $channel->send($notifiable, $notification); + + Http::assertNothingSent(); + } +} +``` + +- [ ] **Step 2: Run tests — confirm they fail** + +```bash +./vendor/bin/phpunit tests/UltraMessageChannelTest.php +``` + +Expected: ERRORS — `UltraMessageChannel` not found. + +- [ ] **Step 3: Implement UltraMessageChannel** + +Create `ultra-message/src/UltraMessageChannel.php`: + +```php +toUltraMessage($notifiable); + + $to = $message->to ?: $notifiable->routeNotificationFor('ultra_message', $notification); + + if (!$to) { + return; + } + + match ($message->type) { + 'text' => $this->client->sendText($to, $message->payload['body'], $message->payload['quoted_id'] ?? null), + 'image' => $this->client->sendImage($to, $message->payload['image'], $message->payload['caption'] ?? ''), + 'document' => $this->client->sendDocument($to, $message->payload['document'], $message->payload['filename'], $message->payload['caption'] ?? ''), + 'audio' => $this->client->sendAudio($to, $message->payload['audio']), + 'voice' => $this->client->sendVoice($to, $message->payload['audio']), + 'video' => $this->client->sendVideo($to, $message->payload['video'], $message->payload['caption'] ?? ''), + 'sticker' => $this->client->sendSticker($to, $message->payload['sticker']), + 'contact' => $this->client->sendContact($to, $message->payload['contact']), + 'location' => $this->client->sendLocation($to, $message->payload['lat'], $message->payload['lng'], $message->payload['address'] ?? ''), + default => throw new UltraMessageException("Unknown message type: {$message->type}"), + }; + } +} +``` + +- [ ] **Step 4: Run tests — confirm they pass** + +```bash +./vendor/bin/phpunit tests/UltraMessageChannelTest.php +``` + +Expected: 3 tests, PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/UltraMessageChannel.php tests/UltraMessageChannelTest.php +git commit -m "feat: implement UltraMessageChannel for Laravel Notifications" +``` + +--- + +### Task 6: UltraMessageFake (test double) + +**Files:** +- Create: `ultra-message/src/UltraMessageFake.php` + +- [ ] **Step 1: Create the fake** + +Create `ultra-message/src/UltraMessageFake.php`: + +```php +sent[] = ['endpoint' => $endpoint, 'data' => $data]; + return ['sent' => 'ok']; + } + + protected function get(string $endpoint, array $query = []): array + { + return []; + } + + public function assertSent(callable $callback): void + { + Assert::assertTrue( + collect($this->sent)->contains($callback), + 'Expected UltraMessage was not sent.' + ); + } + + public function assertNotSent(): void + { + Assert::assertEmpty($this->sent, 'Unexpected UltraMessage messages were sent.'); + } + + public function assertSentCount(int $count): void + { + Assert::assertCount($count, $this->sent, "Expected {$count} messages sent, got " . count($this->sent)); + } + + public function getSent(): array + { + return $this->sent; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/UltraMessageFake.php +git commit -m "feat: add UltraMessageFake for test support" +``` + +--- + +### Task 7: Facade + ServiceProvider + +**Files:** +- Create: `ultra-message/src/Facades/UltraMessage.php` +- Create: `ultra-message/src/UltraMessageServiceProvider.php` + +- [ ] **Step 1: Create the Facade** + +Create `ultra-message/src/Facades/UltraMessage.php`: + +```php +instance('ultra-message.config-resolver', $resolver); + app()->forgetInstance(UltraMessageClient::class); + } +} +``` + +- [ ] **Step 2: Create the ServiceProvider** + +Create `ultra-message/src/UltraMessageServiceProvider.php`: + +```php +mergeConfigFrom(__DIR__ . '/../config/ultra-message.php', 'ultra-message'); + + $this->app->singleton('ultra-message.config-resolver', fn() => null); + + $this->app->singleton(UltraMessageClient::class, function ($app) { + $resolver = $app->make('ultra-message.config-resolver'); + $config = $resolver ? call_user_func($resolver) : config('ultra-message'); + + return new UltraMessageClient($config); + }); + + $this->app->singleton(UltraMessageChannel::class, function ($app) { + return new UltraMessageChannel($app->make(UltraMessageClient::class)); + }); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__ . '/../config/ultra-message.php' => config_path('ultra-message.php'), + ], 'ultra-message-config'); + } + + $this->loadRoutesFrom(__DIR__ . '/../routes/webhook.php'); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Facades/UltraMessage.php src/UltraMessageServiceProvider.php +git commit -m "feat: add Facade and ServiceProvider" +``` + +--- + +### Task 8: Webhook route + controller + event + +**Files:** +- Create: `ultra-message/src/Events/UltraMessageWebhookReceived.php` +- Create: `ultra-message/src/Http/Controllers/WebhookController.php` +- Create: `ultra-message/routes/webhook.php` + +- [ ] **Step 1: Create the event** + +Create `ultra-message/src/Events/UltraMessageWebhookReceived.php`: + +```php +header('X-Hub-Signature-256', ''); + $expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret); + + if (!hash_equals($expected, $signature)) { + abort(403, 'Invalid webhook signature.'); + } + } + + event(new UltraMessageWebhookReceived($request->all())); + + return response('OK', 200); + } +} +``` + +- [ ] **Step 3: Create the webhook route file** + +Create `ultra-message/routes/webhook.php`: + +```php +name('ultra-message.webhook'); +``` + +- [ ] **Step 4: Run the full test suite** + +```bash +./vendor/bin/phpunit +``` + +Expected: All tests pass (8 tests minimum). + +- [ ] **Step 5: Commit** + +```bash +git add src/Events/ src/Http/ routes/ +git commit -m "feat: add webhook route, controller, and event" +``` + +--- + +### Task 9: Push package to GitHub + +> Do this step when the user provides GitHub access. + +- [ ] **Step 1: Create the GitHub repository named `ultra-message` under the Promoseven org** + +- [ ] **Step 2: Push** + +```bash +git remote add origin git@github.com:promoseven/ultra-message.git +git branch -M main +git push -u origin main +``` + +- [ ] **Step 3: Note the repo URL** — needed for OperationModule `composer.json`. + +--- + +## PHASE 2 — OperationModule Integration + +> All remaining steps are inside `C:\Users\IT Department\Desktop\OperationModule\`. + +--- + +### Task 10: Add package to OperationModule via Composer + +**Files:** +- Modify: `composer.json` + +- [ ] **Step 1: Add VCS repository + require entry to composer.json** + +In `composer.json`, add inside `"repositories"` (create the key if missing) and update `"require"`: + +```json +{ + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/promoseven/ultra-message" + } + ], + "require": { + "php": "^8.2", + "barryvdh/laravel-dompdf": "^3.1", + "laravel/framework": "^12.0", + "laravel/tinker": "^2.10.1", + "phpoffice/phpspreadsheet": "^5.7", + "promoseven/ultra-message": "dev-main", + "spatie/laravel-permission": "^6.25" + } +} +``` + +> **Until GitHub is set up:** use a local path repository instead: +> ```json +> { +> "repositories": [ +> { +> "type": "path", +> "url": "../ultra-message" +> } +> ], +> "require": { +> "promoseven/ultra-message": "*" +> } +> } +> ``` + +- [ ] **Step 2: Install** + +```bash +composer require promoseven/ultra-message +``` + +Expected: Package installed, service provider auto-discovered, no errors. + +- [ ] **Step 3: Publish config** + +```bash +php artisan vendor:publish --tag=ultra-message-config +``` + +Expected: `config/ultra-message.php` created in OperationModule. + +- [ ] **Step 4: Commit** + +```bash +git add composer.json composer.lock config/ultra-message.php +git commit -m "feat: install ultra-message package" +``` + +--- + +### Task 11: Settings table migration + Setting model + +**Files:** +- Create: `database/migrations/xxxx_create_settings_table.php` +- Create: `app/Models/Setting.php` + +- [ ] **Step 1: Create the migration** + +```bash +php artisan make:migration create_settings_table +``` + +Edit the generated file in `database/migrations/`: + +```php +id(); + $table->string('key')->unique(); + $table->text('value')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; +``` + +- [ ] **Step 2: Run migration** + +```bash +php artisan migrate +``` + +Expected: `settings` table created, no errors. + +- [ ] **Step 3: Create the Setting model** + +Create `app/Models/Setting.php`: + +```php +first(); + return $setting ? $setting->value : $default; + } + + public static function set(string $key, mixed $value): void + { + static::updateOrCreate(['key' => $key], ['value' => $value]); + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add database/migrations/ app/Models/Setting.php +git commit -m "feat: add settings table and Setting model" +``` + +--- + +### Task 12: Add whatsapp_number to suppliers, customers, and users + +**Files:** +- Create: 3 migration files +- Modify: `app/Models/Supplier.php` +- Modify: `app/Models/Customer.php` +- Modify: `app/Models/User.php` + +- [ ] **Step 1: Create the migrations** + +```bash +php artisan make:migration add_whatsapp_number_to_suppliers_table +php artisan make:migration add_whatsapp_number_to_customers_table +php artisan make:migration add_whatsapp_number_to_users_table +``` + +For each, edit the generated file: + +**`add_whatsapp_number_to_suppliers_table`:** +```php +public function up(): void +{ + Schema::table('suppliers', function (Blueprint $table) { + $table->string('whatsapp_number')->nullable()->after('phone'); + }); +} + +public function down(): void +{ + Schema::table('suppliers', function (Blueprint $table) { + $table->dropColumn('whatsapp_number'); + }); +} +``` + +**`add_whatsapp_number_to_customers_table`:** +```php +public function up(): void +{ + Schema::table('customers', function (Blueprint $table) { + $table->string('whatsapp_number')->nullable()->after('phone'); + }); +} + +public function down(): void +{ + Schema::table('customers', function (Blueprint $table) { + $table->dropColumn('whatsapp_number'); + }); +} +``` + +**`add_whatsapp_number_to_users_table`:** +```php +public function up(): void +{ + Schema::table('users', function (Blueprint $table) { + $table->string('whatsapp_number')->nullable()->after('email'); + }); +} + +public function down(): void +{ + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('whatsapp_number'); + }); +} +``` + +- [ ] **Step 2: Run migrations** + +```bash +php artisan migrate +``` + +Expected: 3 columns added, no errors. + +- [ ] **Step 3: Add routeNotificationFor to Supplier** + +Read `app/Models/Supplier.php` first. Add this method to the class body: + +```php +public function routeNotificationFor(string $channel, mixed $notification = null): ?string +{ + return $this->whatsapp_number; +} +``` + +Also add `'whatsapp_number'` to the `$fillable` array. + +- [ ] **Step 4: Add routeNotificationFor to Customer** + +Read `app/Models/Customer.php` first. Add to the class body: + +```php +public function routeNotificationFor(string $channel, mixed $notification = null): ?string +{ + return $this->whatsapp_number; +} +``` + +Also add `'whatsapp_number'` to `$fillable`. + +- [ ] **Step 5: Add routeNotificationFor to User** + +Read `app/Models/User.php` first. Add to the class body: + +```php +public function routeNotificationFor(string $channel, mixed $notification = null): ?string +{ + return $this->whatsapp_number; +} +``` + +Also add `'whatsapp_number'` to `$fillable`. + +- [ ] **Step 6: Commit** + +```bash +git add database/migrations/ app/Models/Supplier.php app/Models/Customer.php app/Models/User.php +git commit -m "feat: add whatsapp_number field to suppliers, customers, users" +``` + +--- + +### Task 13: Dynamic config + CSRF exclusion in OperationModule + +**Files:** +- Modify: `app/Providers/AppServiceProvider.php` +- Modify: `bootstrap/app.php` + +- [ ] **Step 1: Boot dynamic config resolver in AppServiceProvider** + +Read `app/Providers/AppServiceProvider.php`. In the `boot()` method, add: + +```php +use App\Models\Setting; +use PromoSeven\UltraMessage\Facades\UltraMessage; + +// Inside boot(): +UltraMessage::configUsing(function () { + return [ + 'instance_id' => Setting::get('ultramsg_instance_id', config('ultra-message.instance_id')), + 'token' => Setting::get('ultramsg_token', config('ultra-message.token')), + 'webhook_secret' => Setting::get('ultramsg_webhook_secret', config('ultra-message.webhook_secret')), + 'webhook_path' => Setting::get('ultramsg_webhook_path', config('ultra-message.webhook_path', 'ultra-message/webhook')), + 'timeout' => config('ultra-message.timeout', 30), + 'enabled' => (bool) Setting::get('ultramsg_enabled', config('ultra-message.enabled', true)), + ]; +}); +``` + +- [ ] **Step 2: Exclude webhook path from CSRF in bootstrap/app.php** + +Read `bootstrap/app.php`. Inside the `->withMiddleware(function (Middleware $middleware) {` block, add: + +```php +$middleware->validateCsrfTokens(except: [ + config('ultra-message.webhook_path', 'ultra-message/webhook'), + 'ultra-message/*', +]); +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/Providers/AppServiceProvider.php bootstrap/app.php +git commit -m "feat: wire dynamic config resolver and exclude webhook from CSRF" +``` + +--- + +### Task 14: SettingsController + routes + +**Files:** +- Create: `app/Http/Controllers/SettingsController.php` +- Modify: `routes/web.php` + +- [ ] **Step 1: Create SettingsController** + +Create `app/Http/Controllers/SettingsController.php`: + +```php + Setting::get('ultramsg_enabled', false), + 'instance_id' => Setting::get('ultramsg_instance_id', ''), + 'token' => Setting::get('ultramsg_token', ''), + 'webhook_secret' => Setting::get('ultramsg_webhook_secret', ''), + 'webhook_path' => Setting::get('ultramsg_webhook_path', 'ultra-message/webhook'), + ]; + + return view('settings.integrations', compact('settings')); + } + + public function updateWhatsapp(Request $request): RedirectResponse + { + $request->validate([ + 'instance_id' => ['required', 'string', 'max:100'], + 'token' => ['required', 'string', 'max:255'], + 'webhook_secret' => ['nullable', 'string', 'max:255'], + 'webhook_path' => ['required', 'string', 'max:100'], + ]); + + Setting::set('ultramsg_enabled', $request->boolean('enabled') ? '1' : '0'); + Setting::set('ultramsg_instance_id', $request->instance_id); + Setting::set('ultramsg_token', $request->token); + Setting::set('ultramsg_webhook_secret', $request->webhook_secret ?? ''); + Setting::set('ultramsg_webhook_path', $request->webhook_path); + + return redirect()->route('settings.integrations')->with('success', 'WhatsApp settings saved.'); + } + + public function testWhatsappConnection(): \Illuminate\Http\JsonResponse + { + try { + $status = UltraMessage::getInstanceStatus(); + return response()->json(['success' => true, 'status' => $status]); + } catch (UltraMessageException $e) { + return response()->json(['success' => false, 'message' => $e->getMessage()]); + } + } +} +``` + +- [ ] **Step 2: Add routes to routes/web.php** + +Read `routes/web.php`. Inside the `auth` + `verified` middleware group, add at the end before the closing brace: + +```php +// Settings +Route::middleware('role:Admin')->group(function () { + Route::get('settings/integrations', [SettingsController::class, 'integrations'])->name('settings.integrations'); + Route::post('settings/integrations/whatsapp', [SettingsController::class, 'updateWhatsapp'])->name('settings.integrations.whatsapp'); + Route::get('settings/integrations/test-whatsapp', [SettingsController::class, 'testWhatsappConnection'])->name('settings.integrations.test-whatsapp'); +}); +``` + +Also add the import at the top of the file: +```php +use App\Http\Controllers\SettingsController; +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/Http/Controllers/SettingsController.php routes/web.php +git commit -m "feat: add SettingsController and settings routes" +``` + +--- + +### Task 15: Settings integrations view + +**Files:** +- Create: `resources/views/settings/integrations.blade.php` + +- [ ] **Step 1: Create the view directory** + +```bash +mkdir -p resources/views/settings +``` + +- [ ] **Step 2: Create the view** + +Create `resources/views/settings/integrations.blade.php`: + +```blade + + +

+ Settings — Integrations +

+
+ +
+ + {{-- WhatsApp / UltraMSG Section --}} +
+
+ + + +

WhatsApp (UltraMSG)

+
+ +
+ @csrf + + {{-- Enable toggle --}} +
+
+

Enable WhatsApp Notifications

+

When disabled, no messages will be sent regardless of other settings.

+
+ +
+ +
+ + {{-- Instance ID --}} +
+ + + @error('instance_id')

{{ $message }}

@enderror +
+ + {{-- API Token --}} +
+ +
+ + +
+ @error('token')

{{ $message }}

@enderror +
+ + {{-- Webhook Secret --}} +
+ +
+ + +
+
+ + {{-- Webhook Path --}} +
+ +
+ + {{ url('/') }}/ + + +
+

+ Full URL: {{ url('/') }}/{{ $settings['webhook_path'] }} — paste this in your UltraMSG dashboard. +

+ @error('webhook_path')

{{ $message }}

@enderror +
+ + {{-- Actions --}} +
+ + + +
+ +
+
+ +
+ + +
+``` + +- [ ] **Step 3: Commit** + +```bash +git add resources/views/settings/ +git commit -m "feat: add WhatsApp integration settings view" +``` + +--- + +### Task 16: Add Settings to sidebar (Admin only) + +**Files:** +- Modify: `resources/views/layouts/navigation.blade.php` + +- [ ] **Step 1: Read the current navigation file** + +Read `resources/views/layouts/navigation.blade.php` and find where sidebar navigation links are listed. + +- [ ] **Step 2: Add Settings link visible to Admin role only** + +Find the last navigation `` or nav item group in the sidebar and add after it: + +```blade +@role('Admin') + + + + + + Settings + +@endrole +``` + +- [ ] **Step 3: Commit** + +```bash +git add resources/views/layouts/navigation.blade.php +git commit -m "feat: add Settings sidebar link for Admin role" +``` + +--- + +### Task 17: Add whatsapp_number field to Supplier and Customer forms + +**Files:** +- Modify: `resources/views/purchase/suppliers/create.blade.php` +- Modify: `resources/views/purchase/suppliers/edit.blade.php` +- Modify: `resources/views/sales/customers/create.blade.php` +- Modify: `resources/views/sales/customers/edit.blade.php` +- Modify: `app/Http/Controllers/Purchase/SupplierController.php` +- Modify: `app/Http/Controllers/Sales/CustomerController.php` + +- [ ] **Step 1: Read both supplier form views** + +Read `resources/views/purchase/suppliers/create.blade.php` and `edit.blade.php`. Find where the `phone` field is rendered. After the phone field, add: + +```blade +
+ + +

International format. Used for WhatsApp notifications.

+
+``` + +For `create.blade.php`, use `old('whatsapp_number', '')`. + +- [ ] **Step 2: Read both customer form views** + +Same treatment for `resources/views/sales/customers/create.blade.php` and `edit.blade.php` — add after the phone field. + +- [ ] **Step 3: Update SupplierController store/update** + +Read `app/Http/Controllers/Purchase/SupplierController.php`. In `store()` and `update()` methods, add `'whatsapp_number'` to the validated fields and the `$supplier->fill()` / `Supplier::create()` call. Add to the validation rules: + +```php +'whatsapp_number' => ['nullable', 'string', 'max:20'], +``` + +- [ ] **Step 4: Update CustomerController store/update** + +Read `app/Http/Controllers/Sales/CustomerController.php`. Same as above — add `whatsapp_number` to validation and assignment. + +- [ ] **Step 5: Commit** + +```bash +git add resources/views/purchase/suppliers/ resources/views/sales/customers/ \ + app/Http/Controllers/Purchase/SupplierController.php \ + app/Http/Controllers/Sales/CustomerController.php +git commit -m "feat: add whatsapp_number field to supplier and customer forms" +``` + +--- + +## PHASE 3 — Notifications + +> All files in `C:\Users\IT Department\Desktop\OperationModule\`. + +--- + +### Task 18: PurchaseOrderConfirmedNotification + +**Files:** +- Create: `app/Notifications/Purchase/PurchaseOrderConfirmedNotification.php` +- Modify: `app/Http/Controllers/Purchase/PurchaseOrderController.php` + +- [ ] **Step 1: Create the notification** + +```bash +mkdir -p app/Notifications/Purchase +``` + +Create `app/Notifications/Purchase/PurchaseOrderConfirmedNotification.php`: + +```php +name},\n\nPurchase Order *#{$this->order->number}* has been confirmed.\n\nTotal: {$this->order->currency} {$this->order->total}\nExpected delivery: {$this->order->expected_date}\n\nThank you." + ); + } +} +``` + +- [ ] **Step 2: Trigger notification in PurchaseOrderController** + +Read `app/Http/Controllers/Purchase/PurchaseOrderController.php`. Find the method that confirms/stores a PO (likely `store()` or a `confirm()` action). After the PO is saved/confirmed, add: + +```php +use App\Notifications\Purchase\PurchaseOrderConfirmedNotification; +use Illuminate\Support\Facades\Notification; + +// After PO confirmed: +if ($order->supplier && $order->supplier->whatsapp_number) { + $order->supplier->notify(new PurchaseOrderConfirmedNotification($order)); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/Notifications/Purchase/PurchaseOrderConfirmedNotification.php \ + app/Http/Controllers/Purchase/PurchaseOrderController.php +git commit -m "feat: send WhatsApp notification on PO confirmed" +``` + +--- + +### Task 19: GoodsReceiptConfirmedNotification + +**Files:** +- Create: `app/Notifications/Purchase/GoodsReceiptConfirmedNotification.php` +- Modify: `app/Http/Controllers/Purchase/GoodsReceiptNoteController.php` + +- [ ] **Step 1: Create the notification** + +Create `app/Notifications/Purchase/GoodsReceiptConfirmedNotification.php`: + +```php +grn->number}* has been confirmed and goods received.\n\nPO Reference: {$this->grn->purchaseOrder->number}\nDate: {$this->grn->date}" + ); + } +} +``` + +- [ ] **Step 2: Trigger in GoodsReceiptNoteController confirm method** + +Read `app/Http/Controllers/Purchase/GoodsReceiptNoteController.php`. In the `confirm()` method, after confirming the GRN, add: + +```php +use App\Notifications\Purchase\GoodsReceiptConfirmedNotification; + +// Notify the store manager(s) +$storeManagers = \App\Models\User::role('Store Manager')->whereNotNull('whatsapp_number')->get(); +\Illuminate\Support\Facades\Notification::send($storeManagers, new GoodsReceiptConfirmedNotification($grn)); +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/Notifications/Purchase/GoodsReceiptConfirmedNotification.php \ + app/Http/Controllers/Purchase/GoodsReceiptNoteController.php +git commit -m "feat: send WhatsApp notification on GRN confirmed" +``` + +--- + +### Task 20: SalesOrderConfirmedNotification + +**Files:** +- Create: `app/Notifications/Sales/SalesOrderConfirmedNotification.php` +- Modify: `app/Http/Controllers/Sales/SalesOrderController.php` + +- [ ] **Step 1: Create the notification** + +```bash +mkdir -p app/Notifications/Sales +``` + +Create `app/Notifications/Sales/SalesOrderConfirmedNotification.php`: + +```php +name},\n\nYour order *#{$this->order->number}* has been confirmed.\n\nTotal: {$this->order->currency} {$this->order->total}\n\nWe will keep you updated on the delivery status. Thank you for your business." + ); + } +} +``` + +- [ ] **Step 2: Trigger in SalesOrderController confirm method** + +Read `app/Http/Controllers/Sales/SalesOrderController.php`. In the `confirm()` method, after confirmation, add: + +```php +use App\Notifications\Sales\SalesOrderConfirmedNotification; + +if ($order->customer && $order->customer->whatsapp_number) { + $order->customer->notify(new SalesOrderConfirmedNotification($order)); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/Notifications/Sales/SalesOrderConfirmedNotification.php \ + app/Http/Controllers/Sales/SalesOrderController.php +git commit -m "feat: send WhatsApp notification on sales order confirmed" +``` + +--- + +### Task 21: InvoiceCreatedNotification + +**Files:** +- Create: `app/Notifications/Sales/InvoiceCreatedNotification.php` +- Modify: `app/Http/Controllers/Sales/SalesInvoiceController.php` + +- [ ] **Step 1: Create the notification** + +Create `app/Notifications/Sales/InvoiceCreatedNotification.php`: + +```php +name},\n\nInvoice *#{$this->invoice->number}* is ready.\n\nAmount Due: {$this->invoice->currency} {$this->invoice->total}\nDue Date: {$this->invoice->due_date}\n\nPlease arrange payment at your earliest convenience. Thank you." + ); + } +} +``` + +- [ ] **Step 2: Trigger in SalesInvoiceController store method** + +Read `app/Http/Controllers/Sales/SalesInvoiceController.php`. In `store()`, after the invoice is saved, add: + +```php +use App\Notifications\Sales\InvoiceCreatedNotification; + +if ($invoice->salesOrder?->customer && $invoice->salesOrder->customer->whatsapp_number) { + $invoice->salesOrder->customer->notify(new InvoiceCreatedNotification($invoice)); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/Notifications/Sales/InvoiceCreatedNotification.php \ + app/Http/Controllers/Sales/SalesInvoiceController.php +git commit -m "feat: send WhatsApp notification on sales invoice created" +``` + +--- + +### Task 22: DeliveryDispatchedNotification + +**Files:** +- Create: `app/Notifications/Sales/DeliveryDispatchedNotification.php` +- Modify: `app/Http/Controllers/Sales/DeliveryNoteController.php` + +- [ ] **Step 1: Create the notification** + +Create `app/Notifications/Sales/DeliveryDispatchedNotification.php`: + +```php +name},\n\nYour delivery *#{$this->delivery->number}* has been dispatched and is on its way.\n\nOrder Reference: {$this->delivery->salesOrder->number}\nDispatch Date: {$this->delivery->dispatch_date}\n\nThank you for your business." + ); + } +} +``` + +- [ ] **Step 2: Trigger in DeliveryNoteController dispatch method** + +Read `app/Http/Controllers/Sales/DeliveryNoteController.php`. In the `dispatch()` method, after dispatch, add: + +```php +use App\Notifications\Sales\DeliveryDispatchedNotification; + +if ($delivery->salesOrder?->customer && $delivery->salesOrder->customer->whatsapp_number) { + $delivery->salesOrder->customer->notify(new DeliveryDispatchedNotification($delivery)); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/Notifications/Sales/DeliveryDispatchedNotification.php \ + app/Http/Controllers/Sales/DeliveryNoteController.php +git commit -m "feat: send WhatsApp notification on delivery dispatched" +``` + +--- + +### Task 23: LowStockAlertNotification + +**Files:** +- Create: `app/Notifications/Inventory/LowStockAlertNotification.php` +- Modify: `app/Http/Controllers/Inventory/StockMovementController.php` + +- [ ] **Step 1: Create the notification** + +```bash +mkdir -p app/Notifications/Inventory +``` + +Create `app/Notifications/Inventory/LowStockAlertNotification.php`: + +```php +item->name}* ({$this->item->code})\nCurrent Stock: {$this->stockLevel->quantity} {$this->item->unit}\nReorder Level: {$this->item->reorder_level} {$this->item->unit}\n\nPlease raise a purchase request." + ); + } +} +``` + +- [ ] **Step 2: Trigger in StockMovementController after stock is reduced** + +Read `app/Http/Controllers/Inventory/StockMovementController.php`. In the `store()` method, after the stock movement is recorded, add a check: + +```php +use App\Notifications\Inventory\LowStockAlertNotification; + +// After stock movement saved — check if item is now below reorder level +$stockLevel = \App\Models\StockLevel::where('item_id', $movement->item_id) + ->where('warehouse_id', $movement->warehouse_id) + ->first(); + +if ($stockLevel && $movement->item->reorder_level && $stockLevel->quantity <= $movement->item->reorder_level) { + $storeManagers = \App\Models\User::role('Store Manager')->whereNotNull('whatsapp_number')->get(); + \Illuminate\Support\Facades\Notification::send( + $storeManagers, + new LowStockAlertNotification($movement->item, $stockLevel) + ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/Notifications/Inventory/LowStockAlertNotification.php \ + app/Http/Controllers/Inventory/StockMovementController.php +git commit -m "feat: send WhatsApp low stock alert to store managers" +``` + +--- + +### Task 24: ProductionOrderCompletedNotification + +**Files:** +- Create: `app/Notifications/Production/ProductionOrderCompletedNotification.php` +- Modify: `app/Http/Controllers/Production/ProductionOrderController.php` + +- [ ] **Step 1: Create the notification** + +```bash +mkdir -p app/Notifications/Production +``` + +Create `app/Notifications/Production/ProductionOrderCompletedNotification.php`: + +```php +order->number}* has been completed.\n\nProduct: {$this->order->product->name}\nQuantity: {$this->order->quantity}\nCompleted: " . now()->format('d M Y') + ); + } +} +``` + +- [ ] **Step 2: Trigger in ProductionOrderController complete method** + +Read `app/Http/Controllers/Production/ProductionOrderController.php`. In the `complete()` method, after completion, add: + +```php +use App\Notifications\Production\ProductionOrderCompletedNotification; + +$productionManagers = \App\Models\User::role('Production Manager')->whereNotNull('whatsapp_number')->get(); +\Illuminate\Support\Facades\Notification::send( + $productionManagers, + new ProductionOrderCompletedNotification($order) +); +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/Notifications/Production/ProductionOrderCompletedNotification.php \ + app/Http/Controllers/Production/ProductionOrderController.php +git commit -m "feat: send WhatsApp notification on production order completed" +``` + +--- + +## Self-Review Checklist + +**Spec coverage:** +- [x] Package structure (Tasks 1–8) +- [x] Config + dynamic config resolver (Tasks 7, 13) +- [x] UltraMessageClient — all 16 send/info methods (Task 3) +- [x] UltraMessageMessage DTO — all types (Task 4) +- [x] UltraMessageChannel (Task 5) +- [x] UltraMessageFake + assertSent/assertNotSent/assertSentCount (Task 6) +- [x] Facade with fake() and configUsing() (Task 7) +- [x] Webhook route + HMAC verification + event fire (Task 8) +- [x] GitHub push (Task 9) +- [x] Settings table + Setting model (Task 11) +- [x] whatsapp_number on Supplier/Customer/User + routeNotificationFor (Task 12) +- [x] CSRF exclusion for webhook (Task 13) +- [x] SettingsController with test connection (Task 14) +- [x] Settings UI view with masked fields + test button (Task 15) +- [x] Sidebar link (Admin only) (Task 16) +- [x] WhatsApp field on Supplier + Customer forms (Task 17) +- [x] All 7 notification classes (Tasks 18–24) +- [x] All 7 notification triggers in controllers (Tasks 18–24) + +**No placeholders found.** + +**Type consistency:** `UltraMessageMessage::text/image/document/...` constructors defined in Task 4, used identically in Tasks 5 and 18–24. `UltraMessageChannel` + `UltraMessageClient` method names match throughout. diff --git a/docs/superpowers/specs/2026-05-18-purchase-pipeline-design.md b/docs/superpowers/specs/2026-05-18-purchase-pipeline-design.md new file mode 100644 index 0000000..1fc3cbd --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-purchase-pipeline-design.md @@ -0,0 +1,214 @@ +# Purchase Pipeline — Design Spec +**Date:** 2026-05-18 +**Status:** Approved for implementation + +--- + +## Overview + +Replace the disconnected purchase screens with a single end-to-end pipeline that tracks every purchase like a package delivery — each record shows exactly which stage it is at, who acted last, and what is needed next. + +The pipeline has 8 stages. 3 new database tables are added. All existing models (PurchaseRequest, PurchaseOrder, GRN, SupplierPayment) are reused with minor additions. + +--- + +## The 8 Stages + +| # | Stage | Actor | Model | +|---|-------|-------|-------| +| 1 | Purchase Request created | Operations team | `purchase_requests` (existing) | +| 2 | GM digital signature | GM | `purchase_signatures` (NEW) | +| 3 | Suppliers selected + RFQ links sent | Purchasing team | `rfq_invitations` (NEW) | +| 4 | Suppliers submit quotes via private link | Supplier (public portal) | `supplier_quotes` + `supplier_quote_items` (NEW) | +| 5 | Quote comparison + winner selected | Purchasing team | `supplier_quotes` (awarded flag) | +| 6 | LPO issued to winning supplier | Purchasing team | `purchase_orders` (existing, relabelled LPO) | +| 7 | Materials received at site | Warehouse / Store Manager | `goods_receipt_notes` + `grn_items` (existing, + type field) | +| 8 | Payment / cheque issued | Accounts | `supplier_payments` (existing) | + +The active stage is stored as a `stage` enum column on `purchase_requests`. It advances automatically when each step is completed. + +--- + +## New Database Tables + +### `purchase_signatures` +``` +id +purchase_request_id FK → purchase_requests +signed_by FK → users +signature_image TEXT (base64 PNG from canvas) +signed_at TIMESTAMP +ip_address STRING nullable +``` + +### `rfq_invitations` +``` +id +purchase_request_id FK → purchase_requests +supplier_id FK → suppliers +token STRING(64) UNIQUE — cryptographically random hex +channel ENUM: email | whatsapp | both +sent_at TIMESTAMP nullable +opened_at TIMESTAMP nullable +expires_at TIMESTAMP (sent_at + 7 days) +status ENUM: pending | sent | opened | submitted | declined +timestamps +``` + +### `supplier_quotes` +``` +id +rfq_invitation_id FK → rfq_invitations +purchase_request_id FK → purchase_requests (denormalised for easy querying) +supplier_id FK → suppliers +submitted_at TIMESTAMP +lead_time_days INTEGER nullable +payment_terms STRING nullable +notes TEXT nullable +total_amount DECIMAL(12,3) +is_awarded BOOLEAN default false +award_reason TEXT nullable +awarded_at TIMESTAMP nullable +awarded_by FK → users nullable +timestamps +``` + +### `supplier_quote_items` +``` +id +supplier_quote_id FK → supplier_quotes +description TEXT +unit STRING(50) +quantity DECIMAL(10,3) +unit_price DECIMAL(12,3) +total_price DECIMAL(12,3) +timestamps +``` + +--- + +## Existing Table Changes + +### `purchase_requests` — add column +``` +stage ENUM: draft | gm_approval | rfq | quoting | comparison | lpo | receiving | payment | complete + DEFAULT: draft +``` + +### `grn_items` — add column +``` +type ENUM: inventory | consumable DEFAULT: inventory +``` + +No other existing tables are changed. + +--- + +## Architecture + +### Internal routes (auth-protected) +All existing purchase routes remain. New routes added: + +``` +GET purchase/pipeline purchase.pipeline.index +GET purchase/requests/{id}/sign purchase.requests.sign (GM signature page) +POST purchase/requests/{id}/sign purchase.requests.sign.store +GET purchase/requests/{id}/rfq purchase.requests.rfq (select suppliers) +POST purchase/requests/{id}/rfq purchase.requests.rfq.store (send invitations) +GET purchase/requests/{id}/quotes purchase.requests.quotes (view all quotes) +POST purchase/requests/{id}/quotes/{quote}/award purchase.requests.quotes.award +GET purchase/requests/{id}/compare purchase.requests.compare (comparison table) +``` + +### Public routes (no auth) +``` +GET /rfq/{token} RfqPortalController@show (supplier quote form) +POST /rfq/{token} RfqPortalController@submit (supplier submits quote) +``` + +Token is 64 hex chars generated via `bin2hex(random_bytes(32))`. Validated on every request — expired, already-submitted, or unknown tokens show a clear error page. + +--- + +## UI Components + +### 1. Pipeline index page (`purchase/pipeline`) +Replaces the current fragmented purchase list. Shows all active purchases as cards, each with: +- Vertical timeline showing completed stages (checked, with date + actor) +- Current stage highlighted in amber with action button +- Upcoming stages grayed out + +### 2. GM Signature modal +- HTML5 Canvas — GM draws with mouse or touch +- "Clear" and "Confirm Signature" buttons +- On confirm: canvas serialised to base64 PNG, POSTed to server, stored in `purchase_signatures` +- Signature image displayed on the request detail forever after + +### 3. RFQ Invitation screen +- Checklist of suppliers from the suppliers table (search/filter) +- Per-supplier: toggle WhatsApp / Email / Both +- "Send Invitations" generates tokens, logs `rfq_invitations`, sends messages +- WhatsApp: deep link `https://wa.me/{number}?text=...` with the URL embedded +- Email: Laravel `Mail` with the unique URL + +### 4. Public supplier portal (`/rfq/{token}`) +- No login, no navigation — clean standalone page +- Shows: your company name, request reference, list of items needed (description + quantity) +- Supplier fills: unit price per item, lead time (days), payment terms, notes +- One submit only — redirects to a thank-you screen +- After submission: `rfq_invitations.status` → submitted, `supplier_quotes` record created + +### 5. Quote comparison table +- Side-by-side: one column per supplier who submitted +- Rows: each item, plus totals row, lead time, payment terms +- Lowest price per item highlighted in green +- "Award to this supplier" button per column — opens a modal to enter reason +- Awarding locks the comparison and advances stage to `lpo` + +### 6. Vertical timeline (on every purchase detail) +Each completed stage shows: +- Stage name + icon +- Actor name (who did it) +- Timestamp +- Any key data (e.g. "Signed by Ahmed Al-Rashid", "3 quotes received", "LPO sent to Safety Chemical Trading") + +Current stage pulses amber. Future stages are gray. + +--- + +## New Controllers / Services + +| File | Responsibility | +|------|---------------| +| `PurchasePipelineController` | Pipeline index — lists all purchases by stage | +| `PurchaseSignatureController` | Store GM signature, advance stage | +| `RfqController` | Select suppliers, generate tokens, send invites | +| `SupplierQuoteController` | View quotes, award winner | +| `RfqPortalController` | Public: show form, accept submission | +| `RfqInvitationService` | Generate token, send WhatsApp link + email | +| `PurchaseStageService` | Single place that advances `stage` and logs the event | + +--- + +## New Models + +`PurchaseSignature`, `RfqInvitation`, `SupplierQuote`, `SupplierQuoteItem` + +--- + +## Notification Channels + +**WhatsApp:** Generate a `wa.me` deep-link with the RFQ URL pre-filled in the message body. User clicks the link in the system → WhatsApp opens on their device → they send it to the supplier. (No WhatsApp API key needed.) + +**Email:** Standard Laravel `Mail::to($supplier->email)->send(new RfqInvitationMail($invitation))`. + +--- + +## Constraints & Rules + +- A purchase cannot advance past stage 2 (GM sign) without a signature record +- A purchase cannot advance past stage 4 (quoting) with fewer than 1 submitted quote (minimum 1, recommended 3) +- A token expires 7 days after `sent_at`; expired tokens show an expiry message, not an error +- A quote token can only be submitted once; re-visiting after submit shows the thank-you screen +- Awarding a quote is irreversible — all other quotes are marked `declined` +- GRN items default to `inventory`; the receiving form lets the user toggle each item to `consumable` diff --git a/docs/superpowers/specs/2026-05-19-ultra-message-package-design.md b/docs/superpowers/specs/2026-05-19-ultra-message-package-design.md new file mode 100644 index 0000000..d244551 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-ultra-message-package-design.md @@ -0,0 +1,271 @@ +# Ultra Message — Laravel WhatsApp Package Design + +**Date:** 2026-05-19 +**Project:** OperationModule (SteelERP) + reusable across future Laravel projects +**Package name:** `promoseven/ultra-message` +**API provider:** UltraMSG (https://docs.ultramsg.com/) + +--- + +## Overview + +A reusable Laravel package that wraps the UltraMSG WhatsApp API. It ships as a standalone Composer package hosted on a private GitHub repo and is installed via VCS in any Laravel project. It integrates with Laravel's Notification system, supports queuing, exposes a Facade for one-liner sends, and handles incoming webhooks by firing a generic Laravel event. OperationModule adds a database-backed Settings UI so admins can configure credentials without touching `.env`. + +--- + +## 1. Package Repository + +- **Repo name:** `ultra-message` (GitHub, under Promoseven org) +- **Composer name:** `promoseven/ultra-message` +- **Required in projects via** `composer.json` VCS entry pointing to the GitHub repo +- **Auto-discovered** via Laravel's package discovery (`extra.laravel.providers`) + +--- + +## 2. Package File Structure + +``` +ultra-message/ +├── src/ +│ ├── UltraMessageServiceProvider.php +│ ├── UltraMessageClient.php +│ ├── UltraMessageChannel.php +│ ├── UltraMessageMessage.php +│ ├── UltraMessageException.php +│ ├── Facades/ +│ │ └── UltraMessage.php +│ └── Events/ +│ └── UltraMessageWebhookReceived.php +├── config/ +│ └── ultra-message.php +├── routes/ +│ └── webhook.php +├── tests/ +│ ├── UltraMessageClientTest.php +│ └── UltraMessageChannelTest.php +├── composer.json +└── README.md +``` + +--- + +## 3. Configuration (`config/ultra-message.php`) + +```php +return [ + 'instance_id' => env('ULTRAMSG_INSTANCE_ID'), + 'token' => env('ULTRAMSG_TOKEN'), + 'webhook_secret' => env('ULTRAMSG_WEBHOOK_SECRET', null), + 'webhook_path' => env('ULTRAMSG_WEBHOOK_PATH', 'ultra-message/webhook'), + 'timeout' => env('ULTRAMSG_TIMEOUT', 30), + 'enabled' => env('ULTRAMSG_ENABLED', true), +]; +``` + +**Dynamic config override:** The service provider exposes `UltraMessage::configUsing(callable $resolver)`. When set, the resolver is called at runtime to return an array of config values — used by OperationModule to read credentials from the database instead of `.env`. + +```php +// In OperationModule AppServiceProvider::boot() +UltraMessage::configUsing(fn() => [ + 'instance_id' => Setting::get('ultramsg_instance_id'), + 'token' => Setting::get('ultramsg_token'), + 'enabled' => Setting::get('ultramsg_enabled', true), +]); +``` + +--- + +## 4. The Client (`UltraMessageClient`) + +Wraps all UltraMSG HTTP calls via Laravel's `Http` facade. Base URL: `https://api.ultramsg.com/{instance_id}/messages/`. + +### Outbound methods + +```php +sendText(string $to, string $message, ?string $replyId = null): array +sendImage(string $to, string $imageUrl, string $caption = ''): array +sendDocument(string $to, string $fileUrl, string $filename, string $caption = ''): array +sendAudio(string $to, string $audioUrl): array +sendVoice(string $to, string $audioUrl): array +sendVideo(string $to, string $videoUrl, string $caption = ''): array +sendSticker(string $to, string $stickerUrl): array +sendContact(string $to, string $contactId): array +sendLocation(string $to, float $lat, float $lng, string $address = ''): array +sendReaction(string $to, string $messageId, string $emoji): array +deleteMessage(string $messageId): array +``` + +### Instance / account info + +```php +getInstanceStatus(): array +getChats(): array +getContacts(): array +getGroups(): array +``` + +### Error handling + +All methods throw `UltraMessageException` on HTTP failure (non-2xx) or when the UltraMSG response contains an error field. Callers catch one exception type. + +If `enabled` is `false` in config, all send methods return early silently (no exception, no HTTP call). + +--- + +## 5. Message DTO (`UltraMessageMessage`) + +A fluent DTO used inside Laravel Notifications: + +```php +UltraMessageMessage::text('Order confirmed.') +UltraMessageMessage::image($url, 'Caption') +UltraMessageMessage::document($url, 'invoice.pdf', 'Your invoice') +UltraMessageMessage::audio($url) +UltraMessageMessage::video($url, 'Caption') +UltraMessageMessage::location($lat, $lng, 'Address') +UltraMessageMessage::contact($contactId) +``` + +Each static constructor sets the `type` and relevant properties. The `->to(string $number)` method sets the recipient (overrides the notifiable's route). + +--- + +## 6. Notification Channel (`UltraMessageChannel`) + +Implements `Illuminate\Notifications\Channels\Channel`. When a Notification defines `toUltraMessage()`, this channel: + +1. Resolves the recipient from `$notifiable->routeNotificationFor('ultra_message')` or from `$message->to` +2. Calls the correct `$client->send*()` method based on `$message->type` +3. Supports `ShouldQueue` — the notification queues normally via Laravel's queue system + +### Usage in OperationModule + +```php +class PurchaseOrderConfirmed extends Notification implements ShouldQueue +{ + public function via($notifiable): array + { + return [UltraMessageChannel::class]; + } + + public function toUltraMessage($notifiable): UltraMessageMessage + { + return UltraMessageMessage::text("PO #{$this->order->number} confirmed.") + ->to($notifiable->whatsapp_number); + } +} +``` + +--- + +## 7. Facade (`UltraMessage`) + +Maps to `UltraMessageClient` for one-liner sends outside of the Notification system: + +```php +use PromoSeven\UltraMessage\Facades\UltraMessage; + +UltraMessage::sendText('+971501234567', 'Your invoice is ready.'); +UltraMessage::sendDocument('+971501234567', $pdfUrl, 'invoice.pdf', 'Invoice #123'); +``` + +Also exposes: +```php +UltraMessage::configUsing(callable $resolver); // dynamic config +UltraMessage::fake(); // test mode +UltraMessage::assertSent(callable $callback); // test assertion +``` + +--- + +## 8. Webhook Handling + +The service provider registers `POST /{webhook_path}` (default: `ultra-message/webhook`) outside the `auth` middleware group, excluded from CSRF via `VerifyCsrfToken`. + +On each request: +1. If `webhook_secret` is set — verify HMAC-SHA256 signature from the `X-Hub-Signature-256` header; return `403` on mismatch +2. Fire `UltraMessageWebhookReceived` event with the full raw payload array +3. Return `200 OK` + +**Consuming in OperationModule:** +```php +// In EventServiceProvider +protected $listen = [ + UltraMessageWebhookReceived::class => [ + HandleIncomingWhatsApp::class, + ], +]; +``` + +--- + +## 9. Testing Support + +```php +UltraMessage::fake(); +// ... trigger code that sends ... +UltraMessage::assertSent(fn($msg) => $msg->to === '+971501234567' && $msg->type === 'text'); +UltraMessage::assertNotSent(); +UltraMessage::assertSentCount(3); +``` + +In fake mode, no HTTP calls are made. All sends are recorded in memory for assertion. + +--- + +## 10. Settings UI in OperationModule + +### Database + +A new `settings` table: `id, key (unique), value, created_at, updated_at`. +A `Setting` model with static helpers: `Setting::get($key, $default)` and `Setting::set($key, $value)`. + +### Route & Controller + +``` +GET /settings/integrations → SettingsController@integrations +POST /settings/integrations/whatsapp → SettingsController@updateWhatsapp +``` + +Protected by `auth`, `verified`, and `role:Admin`. + +### View + +A new **Settings** entry in the sidebar (Admin only) leading to an Integrations page with a WhatsApp section: + +| Field | Input type | +|---|---| +| Enable WhatsApp | Toggle switch | +| Instance ID | Text input | +| API Token | Password input (masked, show/hide toggle) | +| Webhook Secret | Password input (masked, show/hide toggle) | +| Webhook URL | Read-only display (auto-generated from `webhook_path`) | +| Connection status | Badge — shows live status via `getInstanceStatus()` | + +On save → toast success/error. "Test Connection" button calls `getInstanceStatus()` and shows result inline. + +--- + +## 11. Integration Points in OperationModule + +Once the package and settings UI are in place, notifications can be wired to these events: + +| Trigger | Message | +|---|---| +| PO confirmed | Supplier: "Your PO #{number} has been confirmed." | +| GRN confirmed | Store: "GRN #{number} received and confirmed." | +| Sales order confirmed | Customer: "Your order #{number} is confirmed." | +| Invoice created | Customer: "Invoice #{number} is ready. Amount: {total}" | +| Delivery dispatched | Customer: "Your delivery is on the way." | +| Low stock alert | Store Manager: "Item {name} is below reorder level." | +| Production order completed | Production Manager: "PO #{number} completed." | + +Each notification is a separate `Notification` class using `UltraMessageChannel`. This wiring is done in OperationModule, not in the package. + +--- + +## 12. Implementation Phases + +1. **Phase 1 — Package** (`ultra-message` repo): ServiceProvider, Client, Channel, Message DTO, Facade, Webhook handler, Fake/test support +2. **Phase 2 — OperationModule integration**: `settings` table + `Setting` model, Settings UI (sidebar + form), dynamic config boot, CSRF exclusion for webhook route +3. **Phase 3 — Notifications**: Wire individual notification classes for each ERP event listed above diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4057aee --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3539 @@ +{ + "name": "OperationModule", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@tailwindcss/forms": "^0.5.2", + "@tailwindcss/vite": "^4.0.0", + "alpinejs": "^3.4.2", + "autoprefixer": "^10.4.2", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "postcss": "^8.4.31", + "tailwindcss": "^3.1.0", + "vite": "^7.0.7" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alpinejs": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz", + "integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", + "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.357", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", + "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz", + "integrity": "sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^7.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2ea7e1d --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.2", + "@tailwindcss/vite": "^4.0.0", + "alpinejs": "^3.4.2", + "autoprefixer": "^10.4.2", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "postcss": "^8.4.31", + "tailwindcss": "^3.1.0", + "vite": "^7.0.7" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e7f0a48 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..49c0612 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..0108865 --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,106 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + /* ── Buttons ── */ + .btn { + @apply inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-1; + } + .btn-primary { + @apply btn bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 shadow-sm; + } + .btn-secondary { + @apply btn bg-white text-slate-700 border border-slate-200 hover:bg-slate-50 focus:ring-slate-300 shadow-sm; + } + .btn-danger { + @apply btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 shadow-sm; + } + .btn-success { + @apply btn bg-emerald-600 text-white hover:bg-emerald-700 focus:ring-emerald-500 shadow-sm; + } + .btn-warning { + @apply btn bg-amber-500 text-white hover:bg-amber-600 focus:ring-amber-400 shadow-sm; + } + .btn-sm { + @apply px-3 py-1.5 text-xs; + } + + /* ── Cards ── */ + .card { + @apply bg-white rounded-2xl shadow-sm border border-slate-200; + } + .card-header { + @apply px-6 py-4 border-b border-slate-100 flex items-center justify-between; + } + .card-body { + @apply p-6; + } + + /* ── Form controls ── */ + .form-label { + @apply block text-sm font-medium text-slate-700 mb-1.5; + } + .form-input { + @apply w-full border border-slate-300 rounded-lg px-3 py-2 text-sm text-slate-800 + focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 + placeholder-slate-400 bg-white transition; + } + .form-select { + @apply form-input cursor-pointer; + } + .form-textarea { + @apply form-input resize-none; + } + .form-input-error { + @apply border-red-400 focus:ring-red-400 focus:border-red-400; + } + + /* ── Tables ── */ + .table-wrapper { + @apply bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden; + } + .table-base { + @apply min-w-full divide-y divide-slate-100 text-sm; + } + .table-base thead { + @apply bg-slate-50; + } + .table-base th { + @apply px-5 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider; + } + .table-base td { + @apply px-5 py-3.5 text-slate-700; + } + .table-base tbody tr { + @apply hover:bg-slate-50 transition-colors; + } + + /* ── Badges ── */ + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold; + } + .badge-green { @apply badge bg-emerald-100 text-emerald-700; } + .badge-red { @apply badge bg-red-100 text-red-700; } + .badge-yellow { @apply badge bg-amber-100 text-amber-700; } + .badge-blue { @apply badge bg-blue-100 text-blue-700; } + .badge-gray { @apply badge bg-slate-100 text-slate-600; } + .badge-violet { @apply badge bg-violet-100 text-violet-700; } + .badge-orange { @apply badge bg-orange-100 text-orange-700; } + + /* ── Page header ── */ + .page-header { + @apply flex items-center justify-between mb-6; + } + .page-title { + @apply text-xl font-bold text-slate-800; + } + .page-subtitle { + @apply text-sm text-slate-500 mt-0.5; + } + + /* ── Section heading ── */ + .section-title { + @apply text-base font-semibold text-slate-700 mb-4; + } +} diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..a8093be --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1,7 @@ +import './bootstrap'; + +import Alpine from 'alpinejs'; + +window.Alpine = Alpine; + +Alpine.start(); diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js new file mode 100644 index 0000000..5f1390b --- /dev/null +++ b/resources/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php new file mode 100644 index 0000000..3d38186 --- /dev/null +++ b/resources/views/auth/confirm-password.blade.php @@ -0,0 +1,27 @@ + +
+ {{ __('This is a secure area of the application. Please confirm your password before continuing.') }} +
+ +
+ @csrf + + +
+ + + + + +
+ +
+ + {{ __('Confirm') }} + +
+
+
diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php new file mode 100644 index 0000000..cb32e08 --- /dev/null +++ b/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,25 @@ + +
+ {{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }} +
+ + + + +
+ @csrf + + +
+ + + +
+ +
+ + {{ __('Email Password Reset Link') }} + +
+
+
diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..78b684f --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,47 @@ + + + + +
+ @csrf + + +
+ + + +
+ + +
+ + + + + +
+ + +
+ +
+ +
+ @if (Route::has('password.request')) + + {{ __('Forgot your password?') }} + + @endif + + + {{ __('Log in') }} + +
+
+
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php new file mode 100644 index 0000000..a857242 --- /dev/null +++ b/resources/views/auth/register.blade.php @@ -0,0 +1,52 @@ + +
+ @csrf + + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + + +
+ + +
+ + + + + +
+ +
+ + {{ __('Already registered?') }} + + + + {{ __('Register') }} + +
+
+
diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php new file mode 100644 index 0000000..a6494cc --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,39 @@ + +
+ @csrf + + + + + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + + +
+ +
+ + {{ __('Reset Password') }} + +
+
+
diff --git a/resources/views/auth/verify-email.blade.php b/resources/views/auth/verify-email.blade.php new file mode 100644 index 0000000..eaf811d --- /dev/null +++ b/resources/views/auth/verify-email.blade.php @@ -0,0 +1,31 @@ + +
+ {{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }} +
+ + @if (session('status') == 'verification-link-sent') +
+ {{ __('A new verification link has been sent to the email address you provided during registration.') }} +
+ @endif + +
+
+ @csrf + +
+ + {{ __('Resend Verification Email') }} + +
+
+ +
+ @csrf + + +
+
+
diff --git a/resources/views/components/application-logo.blade.php b/resources/views/components/application-logo.blade.php new file mode 100644 index 0000000..46579cf --- /dev/null +++ b/resources/views/components/application-logo.blade.php @@ -0,0 +1,3 @@ + + + diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php new file mode 100644 index 0000000..c4bd6e2 --- /dev/null +++ b/resources/views/components/auth-session-status.blade.php @@ -0,0 +1,7 @@ +@props(['status']) + +@if ($status) +
merge(['class' => 'font-medium text-sm text-green-600']) }}> + {{ $status }} +
+@endif diff --git a/resources/views/components/danger-button.blade.php b/resources/views/components/danger-button.blade.php new file mode 100644 index 0000000..d17d288 --- /dev/null +++ b/resources/views/components/danger-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/dropdown-link.blade.php b/resources/views/components/dropdown-link.blade.php new file mode 100644 index 0000000..e0f8ce1 --- /dev/null +++ b/resources/views/components/dropdown-link.blade.php @@ -0,0 +1 @@ +merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out']) }}>{{ $slot }} diff --git a/resources/views/components/dropdown.blade.php b/resources/views/components/dropdown.blade.php new file mode 100644 index 0000000..a46f7c8 --- /dev/null +++ b/resources/views/components/dropdown.blade.php @@ -0,0 +1,35 @@ +@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white']) + +@php +$alignmentClasses = match ($align) { + 'left' => 'ltr:origin-top-left rtl:origin-top-right start-0', + 'top' => 'origin-top', + default => 'ltr:origin-top-right rtl:origin-top-left end-0', +}; + +$width = match ($width) { + '48' => 'w-48', + default => $width, +}; +@endphp + +
+
+ {{ $trigger }} +
+ + +
diff --git a/resources/views/components/input-error.blade.php b/resources/views/components/input-error.blade.php new file mode 100644 index 0000000..9e6da21 --- /dev/null +++ b/resources/views/components/input-error.blade.php @@ -0,0 +1,9 @@ +@props(['messages']) + +@if ($messages) + +@endif diff --git a/resources/views/components/input-label.blade.php b/resources/views/components/input-label.blade.php new file mode 100644 index 0000000..1cc65e2 --- /dev/null +++ b/resources/views/components/input-label.blade.php @@ -0,0 +1,5 @@ +@props(['value']) + + diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php new file mode 100644 index 0000000..70704c1 --- /dev/null +++ b/resources/views/components/modal.blade.php @@ -0,0 +1,78 @@ +@props([ + 'name', + 'show' => false, + 'maxWidth' => '2xl' +]) + +@php +$maxWidth = [ + 'sm' => 'sm:max-w-sm', + 'md' => 'sm:max-w-md', + 'lg' => 'sm:max-w-lg', + 'xl' => 'sm:max-w-xl', + '2xl' => 'sm:max-w-2xl', +][$maxWidth]; +@endphp + +
+
+
+
+ +
+ {{ $slot }} +
+
diff --git a/resources/views/components/nav-link.blade.php b/resources/views/components/nav-link.blade.php new file mode 100644 index 0000000..5c101a2 --- /dev/null +++ b/resources/views/components/nav-link.blade.php @@ -0,0 +1,11 @@ +@props(['active']) + +@php +$classes = ($active ?? false) + ? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out' + : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out'; +@endphp + +merge(['class' => $classes]) }}> + {{ $slot }} + diff --git a/resources/views/components/primary-button.blade.php b/resources/views/components/primary-button.blade.php new file mode 100644 index 0000000..d71f0b6 --- /dev/null +++ b/resources/views/components/primary-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/responsive-nav-link.blade.php b/resources/views/components/responsive-nav-link.blade.php new file mode 100644 index 0000000..43b91e7 --- /dev/null +++ b/resources/views/components/responsive-nav-link.blade.php @@ -0,0 +1,11 @@ +@props(['active']) + +@php +$classes = ($active ?? false) + ? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out' + : 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out'; +@endphp + +merge(['class' => $classes]) }}> + {{ $slot }} + diff --git a/resources/views/components/secondary-button.blade.php b/resources/views/components/secondary-button.blade.php new file mode 100644 index 0000000..b32b69f --- /dev/null +++ b/resources/views/components/secondary-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/text-input.blade.php b/resources/views/components/text-input.blade.php new file mode 100644 index 0000000..da1b12d --- /dev/null +++ b/resources/views/components/text-input.blade.php @@ -0,0 +1,3 @@ +@props(['disabled' => false]) + +merge(['class' => 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm']) }}> diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php new file mode 100644 index 0000000..8091e21 --- /dev/null +++ b/resources/views/dashboard.blade.php @@ -0,0 +1,232 @@ +@extends('layouts.app') +@section('title', 'Dashboard') + +@section('content') + +{{-- Page header --}} +
+

Dashboard

+

Welcome back, {{ Auth::user()->name }}. Here's what's happening today.

+
+ +{{-- KPI Cards --}} +
+ +
+
+ + + +
+
+

Total Sales

+

{{ number_format($totalSales ?? 0, 0) }}

+

All time invoiced

+
+
+ +
+
+ + + +
+
+

Inventory Value

+

{{ number_format($inventoryValue ?? 0, 0) }}

+

All warehouses

+
+
+ +
+
+ + + +
+
+

Production Active

+

{{ $productionInProgress ?? 0 }}

+

Orders in progress

+
+
+ + +
+ + + +
+
+

Purchase Pipeline

+

{{ $purchasePending ?? 0 }}

+

Active pipelines

+
+
+ +
+
+ + + +
+
+

Receivables

+

{{ number_format($outstandingReceivables ?? 0, 0) }}

+

Outstanding

+
+
+ +
+ +{{-- Quick Actions --}} +
+

Quick Actions

+ +
+ +{{-- Module Shortcuts --}} +
+ + + +
+
+

Inventory

+

Stock management

+
+ +
+ +
+
+

Production

+

Manufacturing operations

+
+ +
+ +
+
+

Sales

+

Customer & revenue

+
+ +
+ +
+ +@endsection diff --git a/resources/views/inventory/items/create.blade.php b/resources/views/inventory/items/create.blade.php new file mode 100644 index 0000000..aca22cf --- /dev/null +++ b/resources/views/inventory/items/create.blade.php @@ -0,0 +1,84 @@ +@extends('layouts.app') + +@section('title', 'Add Item') + +@section('content') +
+

Add Item

+

Items / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf +
+ +
+ + +

Unique code for this item

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/inventory/items/edit.blade.php b/resources/views/inventory/items/edit.blade.php new file mode 100644 index 0000000..f0968fe --- /dev/null +++ b/resources/views/inventory/items/edit.blade.php @@ -0,0 +1,82 @@ +@extends('layouts.app') + +@section('title', 'Edit Item') + +@section('content') +
+

Edit Item

+

Items / Edit

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf + @method('PUT') +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ is_active) ? 'checked' : '' }} + class="h-4 w-4 text-blue-600 border-gray-300 rounded"> + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/inventory/items/index.blade.php b/resources/views/inventory/items/index.blade.php new file mode 100644 index 0000000..27d73b1 --- /dev/null +++ b/resources/views/inventory/items/index.blade.php @@ -0,0 +1,204 @@ +@extends('layouts.app') + +@section('title', 'Inventory Items') + +@section('content') + + +
+ + + + + + + + + + + + + + + @forelse($items as $item) + + + + + + + + + + + @empty + + + + @endforelse + +
CodeNameCategoryUOMMin StockCost PriceStatusActions
{{ $item->item_code }}{{ $item->item_name }} + @php + $catBadgeClass = match($item->category) { + 'raw_material' => 'badge-blue', + 'wip' => 'badge-yellow', + 'finished_good' => 'badge-green', + default => 'badge-gray', + }; + $catLabels = ['raw_material' => 'Raw Material', 'wip' => 'WIP', 'finished_good' => 'Finished Good']; + @endphp + + {{ $catLabels[$item->category] ?? ucfirst($item->category) }} + + {{ $item->unit_of_measure }}{{ number_format($item->minimum_stock_level, 2) }}{{ number_format($item->cost_price, 2) }} + @if($item->is_active) + Active + @else + Inactive + @endif + +
+ Edit +
+ @csrf + @method('DELETE') + +
+
+
No items found.
+
+ +@if($items->hasPages()) +
{{ $items->links() }}
+@endif + +{{-- ═══════════ Import Modal ═══════════ --}} + + + + + +@endsection diff --git a/resources/views/inventory/items/pdf.blade.php b/resources/views/inventory/items/pdf.blade.php new file mode 100644 index 0000000..85f3daa --- /dev/null +++ b/resources/views/inventory/items/pdf.blade.php @@ -0,0 +1,149 @@ + + + + +Inventory Items + + + + +
+
+
+ + + +
+
+
SteelERP
+
Manufacturing & Trading
+
+
+
+
Inventory Items List
+
Generated: {{ now()->format('d M Y, H:i') }}
+
Total Records: {{ $items->count() }}
+
+
+ +@php + $fg = $items->where('category', 'finished_good')->count(); + $rm = $items->where('category', 'raw_material')->count(); + $wip = $items->where('category', 'wip')->count(); + $active = $items->where('is_active', true)->count(); + $inactive= $items->where('is_active', false)->count(); + $grouped = $items->groupBy('category'); + $catLabels = ['finished_good' => 'Finished Goods', 'raw_material' => 'Raw Materials', 'wip' => 'Work In Progress']; +@endphp + +
+
{{ $items->count() }}
Total Items
+
{{ $fg }}
Finished Goods
+
{{ $rm }}
Raw Materials
+
{{ $wip }}
WIP
+
{{ $inactive }}
Inactive
+
+ +
+ + + + + + + + + + + + + + @foreach($grouped as $category => $catItems) + + + + @foreach($catItems as $i => $item) + + + + + + + + + + @endforeach + @endforeach + @if($items->isEmpty()) + + @endif + +
CodeItem NameCategoryUOMMin StockCost PriceStatus
{{ $catLabels[$category] ?? ucfirst(str_replace('_', ' ', $category)) }}
{{ $item->item_code }}{{ $item->item_name }} + @if($item->category === 'finished_good') Fin. Good + @elseif($item->category === 'raw_material') Raw Mat. + @else WIP + @endif + {{ $item->unit_of_measure }}{{ number_format($item->minimum_stock_level, 2) }}{{ number_format($item->cost_price, 4) }} + @if($item->is_active) Active + @else Inactive + @endif +
No items found.
+
+ + + + + diff --git a/resources/views/inventory/movements/create.blade.php b/resources/views/inventory/movements/create.blade.php new file mode 100644 index 0000000..8a314e4 --- /dev/null +++ b/resources/views/inventory/movements/create.blade.php @@ -0,0 +1,77 @@ +@extends('layouts.app') + +@section('title', 'Manual Stock Adjustment') + +@section('content') +
+

Manual Stock Adjustment

+

Stock Movements / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/inventory/movements/index.blade.php b/resources/views/inventory/movements/index.blade.php new file mode 100644 index 0000000..6ac46d7 --- /dev/null +++ b/resources/views/inventory/movements/index.blade.php @@ -0,0 +1,59 @@ +@extends('layouts.app') + +@section('title', 'Stock Movements') + +@section('content') + + +
+ + + + + + + + + + + + + @forelse($movements as $movement) + + + + + + + + + @empty + + + + @endforelse + +
ItemWarehouseTypeQuantityReferenceDate
+ {{ $movement->item->item_code ?? '' }} + {{ $movement->item->item_name ?? '' }} + {{ $movement->warehouse->name ?? '' }} + @if(strtolower($movement->type) === 'in') + IN + @else + OUT + @endif + {{ number_format($movement->quantity, 2) }}{{ $movement->reference ?? '-' }}{{ $movement->created_at ? $movement->created_at->format('d M Y') : '' }}
No movements recorded.
+
+ +@if($movements->hasPages()) +
{{ $movements->links() }}
+@endif +@endsection diff --git a/resources/views/inventory/reports/low-stock.blade.php b/resources/views/inventory/reports/low-stock.blade.php new file mode 100644 index 0000000..cbaae48 --- /dev/null +++ b/resources/views/inventory/reports/low-stock.blade.php @@ -0,0 +1,61 @@ +@extends('layouts.app') + +@section('title', 'Low Stock Report') + +@section('content') +
+

Low Stock Report

+

Items currently below their minimum stock level

+
+ +@if(isset($stocks) && $stocks->count() > 0) +
+ {{ $stocks->count() }} item(s) are below minimum stock level. Immediate action may be required. +
+@endif + +
+ + + + + + + + + + + + + + + @forelse($stocks as $stock) + @php $shortage = $stock->item->minimum_stock_level - $stock->quantity; @endphp + + + + + + + + + + + @empty + + + + @endforelse + +
Item CodeItem NameCategoryWarehouseCurrent QtyMin StockShortageStatus
{{ $stock->item->item_code ?? '' }}{{ $stock->item->item_name ?? '' }} + @php + $catLabels = ['raw_material'=>'Raw Material','wip'=>'WIP','finished_good'=>'Finished Good']; + @endphp + {{ $catLabels[$stock->item->category ?? ''] ?? ucfirst($stock->item->category ?? '') }} + {{ $stock->warehouse->name ?? '' }}{{ number_format($stock->quantity, 2) }}{{ number_format($stock->item->minimum_stock_level ?? 0, 2) }}{{ number_format($shortage, 2) }} + LOW STOCK +
+ All items are above minimum stock levels. +
+
+@endsection diff --git a/resources/views/inventory/reports/movement.blade.php b/resources/views/inventory/reports/movement.blade.php new file mode 100644 index 0000000..bbf3009 --- /dev/null +++ b/resources/views/inventory/reports/movement.blade.php @@ -0,0 +1,80 @@ +@extends('layouts.app') + +@section('title', 'Movement Report') + +@section('content') +
+

Movement Report

+

View stock movements within a date range

+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ + + + + + + + + + + + @forelse($movements as $movement) + + + + + + + + @empty + + + + @endforelse + +
ItemWarehouseTypeQuantityDate
+ {{ $movement->item->item_code ?? '' }} + {{ $movement->item->item_name ?? '' }} + {{ $movement->warehouse->name ?? '' }} + @if(strtolower($movement->type) === 'in') + IN + @else + OUT + @endif + {{ number_format($movement->quantity, 2) }}{{ $movement->created_at ? $movement->created_at->format('d M Y') : '' }}
No movements found for selected filters.
+
+ +@if(isset($movements) && $movements->hasPages()) +
{{ $movements->links() }}
+@endif +@endsection diff --git a/resources/views/inventory/reports/summary.blade.php b/resources/views/inventory/reports/summary.blade.php new file mode 100644 index 0000000..328cca8 --- /dev/null +++ b/resources/views/inventory/reports/summary.blade.php @@ -0,0 +1,45 @@ +@extends('layouts.app') + +@section('title', 'Inventory Summary') + +@section('content') +
+

Inventory Summary

+

Current stock levels across all warehouses

+
+ +
+ + + + + + + + + + + + @forelse($stocks as $stock) + @php $isLow = $stock->quantity < $stock->item->minimum_stock_level; @endphp + + + + + + + + @empty + + + + @endforelse + +
Item CodeItem NameWarehouseQuantityMin Stock
{{ $stock->item->item_code ?? '' }} + {{ $stock->item->item_name ?? '' }} + @if($isLow) + LOW + @endif + {{ $stock->warehouse->name ?? '' }}{{ number_format($stock->quantity, 2) }}{{ number_format($stock->item->minimum_stock_level ?? 0, 2) }}
No stock data available.
+
+@endsection diff --git a/resources/views/inventory/reports/valuation.blade.php b/resources/views/inventory/reports/valuation.blade.php new file mode 100644 index 0000000..629e108 --- /dev/null +++ b/resources/views/inventory/reports/valuation.blade.php @@ -0,0 +1,62 @@ +@extends('layouts.app') + +@section('title', 'Inventory Valuation') + +@section('content') +
+

Inventory Valuation

+

Total value of current stock

+
+ +
+ + + + + + + + + + + + + @forelse($valuations as $row) + + + + + + + + + @empty + + + + @endforelse + + @if(isset($grandTotal)) + + + + + + + @endif +
Item CodeItem NameCategoryTotal QtyCost PriceTotal Value
{{ $row->item_code }}{{ $row->item_name }} + @php + $catBadgeClass = match($row->category) { + 'raw_material' => 'badge-blue', + 'wip' => 'badge-yellow', + 'finished_good' => 'badge-green', + default => 'badge-gray', + }; + $catLabels = ['raw_material'=>'Raw Material','wip'=>'WIP','finished_good'=>'Finished Good']; + @endphp + + {{ $catLabels[$row->category] ?? ucfirst($row->category) }} + + {{ number_format($row->total_qty, 2) }}{{ number_format($row->cost_price, 2) }}{{ number_format($row->total_value, 2) }}
No valuation data available.
Grand Total{{ number_format($grandTotal, 2) }}
+
+@endsection diff --git a/resources/views/inventory/warehouses/create.blade.php b/resources/views/inventory/warehouses/create.blade.php new file mode 100644 index 0000000..13f2b97 --- /dev/null +++ b/resources/views/inventory/warehouses/create.blade.php @@ -0,0 +1,62 @@ +@extends('layouts.app') + +@section('title', 'Add Warehouse') + +@section('content') +
+

Add Warehouse

+

Warehouses / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/inventory/warehouses/edit.blade.php b/resources/views/inventory/warehouses/edit.blade.php new file mode 100644 index 0000000..8e032b6 --- /dev/null +++ b/resources/views/inventory/warehouses/edit.blade.php @@ -0,0 +1,62 @@ +@extends('layouts.app') + +@section('title', 'Edit Warehouse') + +@section('content') +
+

Edit Warehouse

+

Warehouses / Edit

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf + @method('PUT') +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ is_active) ? 'checked' : '' }} + class="h-4 w-4 text-blue-600 border-gray-300 rounded"> + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/inventory/warehouses/index.blade.php b/resources/views/inventory/warehouses/index.blade.php new file mode 100644 index 0000000..5fb9e5e --- /dev/null +++ b/resources/views/inventory/warehouses/index.blade.php @@ -0,0 +1,64 @@ +@extends('layouts.app') + +@section('title', 'Warehouses') + +@section('content') + + +
+ + + + + + + + + + + + @forelse($warehouses as $warehouse) + + + + + + + + @empty + + + + @endforelse + +
CodeNameLocationStatusActions
{{ $warehouse->code }}{{ $warehouse->name }}{{ $warehouse->location }} + @if($warehouse->is_active) + Active + @else + Inactive + @endif + +
+ Edit +
+ @csrf + @method('DELETE') + +
+
+
No warehouses found.
+
+ +@if($warehouses->hasPages()) +
{{ $warehouses->links() }}
+@endif +@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..2ceca37 --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,440 @@ + + + + + + + @yield('title', 'Dashboard') — SteelERP + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + +
+ + {{-- ════════════════ SIDEBAR ════════════════ --}} + + + {{-- Mobile overlay --}} + + + {{-- ════════════════ MAIN AREA ════════════════ --}} +
+ + {{-- Top bar --}} +
+ + +
+ @yield('title', 'Dashboard') +
+ +
+ {{ now()->format('l, d M Y') }} +
+
+
+ {{ strtoupper(substr(Auth::user()->name ?? 'U', 0, 1)) }} +
+ {{ Auth::user()->name ?? 'User' }} +
+
+
+ + {{-- Content --}} +
+ + @yield('content') +
+
+
+ +{{-- ── Toast notification container ── --}} +
+ + + + + +{{-- ── Global delete confirmation modal ── --}} + + + + + + + diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php new file mode 100644 index 0000000..11feb47 --- /dev/null +++ b/resources/views/layouts/guest.blade.php @@ -0,0 +1,30 @@ + + + + + + + + {{ config('app.name', 'Laravel') }} + + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
+ + + +
+ +
+ {{ $slot }} +
+
+ + diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php new file mode 100644 index 0000000..c2d3a65 --- /dev/null +++ b/resources/views/layouts/navigation.blade.php @@ -0,0 +1,100 @@ + diff --git a/resources/views/mail/rfq-invitation.blade.php b/resources/views/mail/rfq-invitation.blade.php new file mode 100644 index 0000000..d9b317f --- /dev/null +++ b/resources/views/mail/rfq-invitation.blade.php @@ -0,0 +1,40 @@ + + + + + + Quote Request + + +
+ + +
+
Quote Request
+
{{ $invitation->purchaseRequest->request_number }}
+ @if($invitation->purchaseRequest->project_name) +
{{ $invitation->purchaseRequest->project_name }}
+ @endif +
+ + +
+

Dear {{ $invitation->supplier->name }},

+

+ You have been invited to submit a price quotation. Please click the button below to view the required items and submit your quote. The link is private to your company and expires in 7 days. +

+ + + Submit My Quote → + + +

+ This link can only be submitted once and expires on {{ $invitation->expires_at->format('d M Y') }}. + If the button doesn't work, copy this URL into your browser:
+ {{ route('rfq.show', $invitation->token) }} +

+
+
+ + diff --git a/resources/views/production/bom/create.blade.php b/resources/views/production/bom/create.blade.php new file mode 100644 index 0000000..910b325 --- /dev/null +++ b/resources/views/production/bom/create.blade.php @@ -0,0 +1,69 @@ +@extends('layouts.app') + +@section('title', 'Add BOM Entry') + +@section('content') +
+

Add BOM Entry

+

Bill of Materials / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/production/bom/edit.blade.php b/resources/views/production/bom/edit.blade.php new file mode 100644 index 0000000..9952f15 --- /dev/null +++ b/resources/views/production/bom/edit.blade.php @@ -0,0 +1,69 @@ +@extends('layouts.app') + +@section('title', 'Edit BOM Entry') + +@section('content') +
+

Edit BOM Entry

+

Bill of Materials / Edit

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf + @method('PUT') +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/production/bom/index.blade.php b/resources/views/production/bom/index.blade.php new file mode 100644 index 0000000..29ba2de --- /dev/null +++ b/resources/views/production/bom/index.blade.php @@ -0,0 +1,65 @@ +@extends('layouts.app') + +@section('title', 'Bill of Materials') + +@section('content') + + +@php $currentProduct = null; @endphp +@forelse($bomEntries as $entry) + @if($currentProduct !== ($entry->product->item_name ?? '')) + @if($currentProduct !== null) + + @endif + @php $currentProduct = $entry->product->item_name ?? ''; @endphp +
+
+

{{ $currentProduct }}

+ {{ $entry->product->item_code ?? '' }} +
+ + + + + + + + + + + @endif + + + + + + +@empty +
+ No BOM entries found. Add the first one. +
+@endforelse +@if($currentProduct !== null && $bomEntries->count()) + +
Raw MaterialQty RequiredUOMActions
{{ $entry->rawMaterial->item_name ?? '' }}{{ number_format($entry->quantity_required, 2) }}{{ $entry->unit_of_measure }} +
+ Edit +
+ @csrf + @method('DELETE') + +
+
+
+
+@endif +@endsection diff --git a/resources/views/production/material-issues/index.blade.php b/resources/views/production/material-issues/index.blade.php new file mode 100644 index 0000000..340157a --- /dev/null +++ b/resources/views/production/material-issues/index.blade.php @@ -0,0 +1,133 @@ +@extends('layouts.app') + +@section('title', 'Material Issues') + +@section('content') + + +
+ + + + + + + + + + + + + @forelse($materialIssues as $issue) + + + + + + + + + @empty + + + + @endforelse + +
Production OrderItemWarehouseQuantityIssue DateNotes
+ + {{ $issue->productionOrder->order_number ?? 'PRD-' . str_pad($issue->production_order_id, 5, '0', STR_PAD_LEFT) }} + + {{ $issue->item->item_name ?? '' }}{{ $issue->warehouse->name ?? '' }}{{ number_format($issue->quantity, 2) }}{{ $issue->issue_date ? \Carbon\Carbon::parse($issue->issue_date)->format('d M Y') : '' }}{{ $issue->notes ?? '-' }}
No material issues found.
+
+ +@if($materialIssues->hasPages()) +
{{ $materialIssues->links() }}
+@endif + + +
+

Issue New Material

+ + @if($errors->any()) +
+
    + @foreach($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+
+ @csrf +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+
+
+@endsection diff --git a/resources/views/production/orders/create.blade.php b/resources/views/production/orders/create.blade.php new file mode 100644 index 0000000..df86bc4 --- /dev/null +++ b/resources/views/production/orders/create.blade.php @@ -0,0 +1,61 @@ +@extends('layouts.app') + +@section('title', 'New Production Order') + +@section('content') +
+

New Production Order

+

Production Orders / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/production/orders/edit.blade.php b/resources/views/production/orders/edit.blade.php new file mode 100644 index 0000000..c710cc4 --- /dev/null +++ b/resources/views/production/orders/edit.blade.php @@ -0,0 +1,62 @@ +@extends('layouts.app') + +@section('title', 'Edit Production Order') + +@section('content') +
+

Edit Production Order

+

Production Orders / Edit

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf + @method('PUT') +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/production/orders/index.blade.php b/resources/views/production/orders/index.blade.php new file mode 100644 index 0000000..97d7026 --- /dev/null +++ b/resources/views/production/orders/index.blade.php @@ -0,0 +1,82 @@ +@extends('layouts.app') + +@section('title', 'Production Orders') + +@section('content') + + +
+ + + + + + + + + + + + + + @forelse($orders as $order) + + + + + + + + + + @empty + + + + @endforelse + +
Order #ProductQty to ProduceProducedDateStatusActions
{{ $order->order_number ?? 'PRD-' . str_pad($order->id, 5, '0', STR_PAD_LEFT) }}{{ $order->product->item_name ?? '' }}{{ number_format($order->quantity_to_produce, 2) }}{{ number_format($order->quantity_produced ?? 0, 2) }}{{ $order->production_date ? \Carbon\Carbon::parse($order->production_date)->format('d M Y') : '' }} + @php + $badgeClass = match($order->status ?? 'pending') { + 'pending' => 'badge-yellow', + 'in_progress' => 'badge-blue', + 'completed' => 'badge-green', + 'cancelled' => 'badge-red', + default => 'badge-gray', + }; + @endphp + {{ ucwords(str_replace('_', ' ', $order->status ?? 'pending')) }} + +
+ View + @if($order->status === 'pending') +
+ @csrf + @method('PATCH') + +
+ @endif + @if($order->status === 'in_progress') +
+ @csrf + @method('PATCH') + +
+ @endif + Edit +
+
No production orders found.
+
+ +@if($orders->hasPages()) +
{{ $orders->links() }}
+@endif +@endsection diff --git a/resources/views/production/orders/show.blade.php b/resources/views/production/orders/show.blade.php new file mode 100644 index 0000000..38024e6 --- /dev/null +++ b/resources/views/production/orders/show.blade.php @@ -0,0 +1,168 @@ +@extends('layouts.app') + +@section('title', 'Production Order') + +@section('content') + + + +
+
+

Order Details

+
+
+
Order Number
+
{{ $order->order_number ?? 'PRD-' . str_pad($order->id, 5, '0', STR_PAD_LEFT) }}
+
+
+
Product
+
{{ $order->product->item_name ?? '' }}
+
+
+
Qty to Produce
+
{{ number_format($order->quantity_to_produce, 2) }}
+
+
+
Qty Produced
+
{{ number_format($order->quantity_produced ?? 0, 2) }}
+
+
+
Production Date
+
{{ $order->production_date ? \Carbon\Carbon::parse($order->production_date)->format('d M Y') : '-' }}
+
+
+
Status
+
+ @php + $badgeClass = match($order->status ?? 'pending') { + 'pending' => 'badge-yellow', + 'in_progress' => 'badge-blue', + 'completed' => 'badge-green', + default => 'badge-gray', + }; + @endphp + {{ ucwords(str_replace('_', ' ', $order->status ?? 'pending')) }} +
+
+ @if($order->notes) +
+
Notes
+
{{ $order->notes }}
+
+ @endif +
+
+ + +
+

Bill of Materials

+ @if(isset($bom) && $bom->count()) + + + + + + + + + + @foreach($bom as $bomItem) + + + + + + @endforeach + +
MaterialQty RequiredUOM
{{ $bomItem->rawMaterial->item_name ?? '' }}{{ number_format($bomItem->quantity_required, 2) }}{{ $bomItem->unit_of_measure }}
+ @else +

No BOM defined for this product.

+ @endif +
+
+ + +@if(isset($materialIssues) && $materialIssues->count()) +
+
+

Material Issues

+ + Issue Material +
+ + + + + + + + + + + @foreach($materialIssues as $issue) + + + + + + + @endforeach + +
ItemWarehouseQtyDate
{{ $issue->item->item_name ?? '' }}{{ $issue->warehouse->name ?? '' }}{{ number_format($issue->quantity, 2) }}{{ $issue->issue_date ? \Carbon\Carbon::parse($issue->issue_date)->format('d M Y') : '' }}
+
+@endif + + +@if(isset($outputs) && $outputs->count()) +
+
+

Production Output

+ + Record Output +
+ + + + + + + + + + + @foreach($outputs as $output) + + + + + + + @endforeach + +
ItemWarehouseQtyDate
{{ $output->item->item_name ?? '' }}{{ $output->warehouse->name ?? '' }}{{ number_format($output->quantity, 2) }}{{ $output->output_date ? \Carbon\Carbon::parse($output->output_date)->format('d M Y') : '' }}
+
+@endif +@endsection diff --git a/resources/views/production/outputs/index.blade.php b/resources/views/production/outputs/index.blade.php new file mode 100644 index 0000000..04cb242 --- /dev/null +++ b/resources/views/production/outputs/index.blade.php @@ -0,0 +1,133 @@ +@extends('layouts.app') + +@section('title', 'Production Output') + +@section('content') + + +
+ + + + + + + + + + + + + @forelse($outputs as $output) + + + + + + + + + @empty + + + + @endforelse + +
Production OrderItemWarehouseQuantityOutput DateNotes
+ + {{ $output->productionOrder->order_number ?? 'PRD-' . str_pad($output->production_order_id, 5, '0', STR_PAD_LEFT) }} + + {{ $output->item->item_name ?? '' }}{{ $output->warehouse->name ?? '' }}{{ number_format($output->quantity, 2) }}{{ $output->output_date ? \Carbon\Carbon::parse($output->output_date)->format('d M Y') : '' }}{{ $output->notes ?? '-' }}
No production outputs recorded.
+
+ +@if($outputs->hasPages()) +
{{ $outputs->links() }}
+@endif + + +
+

Record Production Output

+ + @if($errors->any()) +
+
    + @foreach($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+
+ @csrf +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+
+
+@endsection diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php new file mode 100644 index 0000000..e0e1d38 --- /dev/null +++ b/resources/views/profile/edit.blade.php @@ -0,0 +1,29 @@ + + +

+ {{ __('Profile') }} +

+
+ +
+
+
+
+ @include('profile.partials.update-profile-information-form') +
+
+ +
+
+ @include('profile.partials.update-password-form') +
+
+ +
+
+ @include('profile.partials.delete-user-form') +
+
+
+
+
diff --git a/resources/views/profile/partials/delete-user-form.blade.php b/resources/views/profile/partials/delete-user-form.blade.php new file mode 100644 index 0000000..edeeb4a --- /dev/null +++ b/resources/views/profile/partials/delete-user-form.blade.php @@ -0,0 +1,55 @@ +
+
+

+ {{ __('Delete Account') }} +

+ +

+ {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }} +

+
+ + {{ __('Delete Account') }} + + +
+ @csrf + @method('delete') + +

+ {{ __('Are you sure you want to delete your account?') }} +

+ +

+ {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} +

+ +
+ + + + + +
+ +
+ + {{ __('Cancel') }} + + + + {{ __('Delete Account') }} + +
+
+
+
diff --git a/resources/views/profile/partials/update-password-form.blade.php b/resources/views/profile/partials/update-password-form.blade.php new file mode 100644 index 0000000..eaca1ac --- /dev/null +++ b/resources/views/profile/partials/update-password-form.blade.php @@ -0,0 +1,48 @@ +
+
+

+ {{ __('Update Password') }} +

+ +

+ {{ __('Ensure your account is using a long, random password to stay secure.') }} +

+
+ +
+ @csrf + @method('put') + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ {{ __('Save') }} + + @if (session('status') === 'password-updated') +

{{ __('Saved.') }}

+ @endif +
+
+
diff --git a/resources/views/profile/partials/update-profile-information-form.blade.php b/resources/views/profile/partials/update-profile-information-form.blade.php new file mode 100644 index 0000000..5ae3d35 --- /dev/null +++ b/resources/views/profile/partials/update-profile-information-form.blade.php @@ -0,0 +1,64 @@ +
+
+

+ {{ __('Profile Information') }} +

+ +

+ {{ __("Update your account's profile information and email address.") }} +

+
+ +
+ @csrf +
+ +
+ @csrf + @method('patch') + +
+ + + +
+ +
+ + + + + @if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail()) +
+

+ {{ __('Your email address is unverified.') }} + + +

+ + @if (session('status') === 'verification-link-sent') +

+ {{ __('A new verification link has been sent to your email address.') }} +

+ @endif +
+ @endif +
+ +
+ {{ __('Save') }} + + @if (session('status') === 'profile-updated') +

{{ __('Saved.') }}

+ @endif +
+
+
diff --git a/resources/views/purchase/grns/create.blade.php b/resources/views/purchase/grns/create.blade.php new file mode 100644 index 0000000..7cc141d --- /dev/null +++ b/resources/views/purchase/grns/create.blade.php @@ -0,0 +1,156 @@ +@extends('layouts.app') + +@section('title', 'New Goods Receipt Note') + +@section('content') +
+

New Goods Receipt Note

+

GRNs / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+ @csrf + +
+

GRN Details

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+

Items Received

+
+ + + + + + + + + + + + + + +
ItemPO QtyQty ReceivedType
Select a Purchase Order to load items.
+
+
+ +
+ + Cancel +
+
+ + +@endsection diff --git a/resources/views/purchase/grns/index.blade.php b/resources/views/purchase/grns/index.blade.php new file mode 100644 index 0000000..4db4bc8 --- /dev/null +++ b/resources/views/purchase/grns/index.blade.php @@ -0,0 +1,64 @@ +@extends('layouts.app') + +@section('title', 'Goods Receipt Notes') + +@section('content') + + +
+ + + + + + + + + + + + + + @forelse($grns as $grn) + + + + + + + + + + @empty + + + + @endforelse + +
GRN #PO #SupplierWarehouseDateStatusActions
{{ $grn->grn_number ?? 'GRN-' . str_pad($grn->id, 5, '0', STR_PAD_LEFT) }}{{ $grn->purchaseOrder->po_number ?? 'PO-' . str_pad($grn->purchase_order_id, 5, '0', STR_PAD_LEFT) }}{{ $grn->purchaseOrder->supplier->name ?? '' }}{{ $grn->warehouse->name ?? '' }}{{ $grn->received_date ? \Carbon\Carbon::parse($grn->received_date)->format('d M Y') : '' }} + {{ ucfirst($grn->status ?? 'received') }} + +
+ View +
+ @csrf + @method('DELETE') + +
+
+
No GRNs found.
+
+ +@if($grns->hasPages()) +
{{ $grns->links() }}
+@endif +@endsection diff --git a/resources/views/purchase/grns/show.blade.php b/resources/views/purchase/grns/show.blade.php new file mode 100644 index 0000000..4e3cccd --- /dev/null +++ b/resources/views/purchase/grns/show.blade.php @@ -0,0 +1,84 @@ +@extends('layouts.app') + +@section('title', 'Goods Receipt Note') + +@section('content') + + +
+
+

GRN Details

+
+
+
GRN Number
+
{{ $grn->grn_number ?? 'GRN-' . str_pad($grn->id, 5, '0', STR_PAD_LEFT) }}
+
+ +
+
Supplier
+
{{ $grn->purchaseOrder->supplier->name ?? '-' }}
+
+
+
Warehouse
+
{{ $grn->warehouse->name ?? '-' }}
+
+
+
Received Date
+
{{ $grn->received_date ? \Carbon\Carbon::parse($grn->received_date)->format('d M Y') : '-' }}
+
+
+
Status
+
{{ ucfirst($grn->status ?? 'received') }}
+
+ @if($grn->notes) +
+
Notes
+
{{ $grn->notes }}
+
+ @endif +
+
+
+ + +
+
+

Items Received

+
+ + + + + + + + + + @forelse($grn->items as $item) + + + + + + @empty + + @endforelse + +
ItemPO QtyQty Received
{{ $item->item->item_name ?? '' }}{{ number_format($item->quantity_ordered ?? 0, 2) }}{{ number_format($item->quantity_received, 2) }}
No items recorded.
+
+@endsection diff --git a/resources/views/purchase/invoices/create.blade.php b/resources/views/purchase/invoices/create.blade.php new file mode 100644 index 0000000..a128b64 --- /dev/null +++ b/resources/views/purchase/invoices/create.blade.php @@ -0,0 +1,112 @@ +@extends('layouts.app') + +@section('title', 'New Supplier Invoice') + +@section('content') +
+

New Supplier Invoice

+

Supplier Invoices / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+ + +@endsection diff --git a/resources/views/purchase/invoices/edit.blade.php b/resources/views/purchase/invoices/edit.blade.php new file mode 100644 index 0000000..e437dcf --- /dev/null +++ b/resources/views/purchase/invoices/edit.blade.php @@ -0,0 +1,86 @@ +@extends('layouts.app') + +@section('title', 'Edit Supplier Invoice') + +@section('content') +
+

Edit Supplier Invoice

+

Supplier Invoices / Edit

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf + @method('PUT') +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+ + +@endsection diff --git a/resources/views/purchase/invoices/index.blade.php b/resources/views/purchase/invoices/index.blade.php new file mode 100644 index 0000000..864c17b --- /dev/null +++ b/resources/views/purchase/invoices/index.blade.php @@ -0,0 +1,78 @@ +@extends('layouts.app') + +@section('title', 'Supplier Invoices') + +@section('content') + + +
+ + + + + + + + + + + + + + + + @forelse($invoices as $invoice) + @php $outstanding = $invoice->total_amount - $invoice->paid_amount; @endphp + + + + + + + + + + + + @empty + + + + @endforelse + +
Invoice #SupplierPO #DateTotalPaidOutstandingStatusActions
{{ $invoice->invoice_number }}{{ $invoice->supplier->name ?? '' }}{{ $invoice->purchaseOrder->po_number ?? '-' }}{{ $invoice->invoice_date ? \Carbon\Carbon::parse($invoice->invoice_date)->format('d M Y') : '' }}{{ number_format($invoice->total_amount, 2) }}{{ number_format($invoice->paid_amount, 2) }}{{ number_format($outstanding, 2) }} + @php + $badgeClass = match($invoice->status ?? 'unpaid') { + 'unpaid' => 'badge-red', + 'partial' => 'badge-yellow', + 'paid' => 'badge-green', + default => 'badge-gray', + }; + @endphp + {{ ucfirst($invoice->status ?? 'unpaid') }} + +
+ Pay + Edit +
+ @csrf + @method('DELETE') + +
+
+
No invoices found.
+
+ +@if($invoices->hasPages()) +
{{ $invoices->links() }}
+@endif +@endsection diff --git a/resources/views/purchase/orders/create.blade.php b/resources/views/purchase/orders/create.blade.php new file mode 100644 index 0000000..7fe29f7 --- /dev/null +++ b/resources/views/purchase/orders/create.blade.php @@ -0,0 +1,196 @@ +@extends('layouts.app') + +@section('title', 'New Purchase Order') + +@section('content') +
+

New Purchase Order

+

Purchase Orders / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+ @csrf + + +
+

Order Details

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+

Order Items

+ +
+ +
+ + + + + + + + + + + + + + + + + + + +
ItemQuantityRateTotal
+ + + + + + + + + +
+
+ +
+
+ Grand Total: 0.00 +
+
+
+ +
+ + Cancel +
+
+ + +@endsection diff --git a/resources/views/purchase/orders/edit.blade.php b/resources/views/purchase/orders/edit.blade.php new file mode 100644 index 0000000..90c1f81 --- /dev/null +++ b/resources/views/purchase/orders/edit.blade.php @@ -0,0 +1,72 @@ +@extends('layouts.app') + +@section('title', 'Edit Purchase Order') + +@section('content') +
+

Edit Purchase Order

+

Purchase Orders / Edit

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf + @method('PUT') +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/purchase/orders/index.blade.php b/resources/views/purchase/orders/index.blade.php new file mode 100644 index 0000000..c2693c4 --- /dev/null +++ b/resources/views/purchase/orders/index.blade.php @@ -0,0 +1,74 @@ +@extends('layouts.app') + +@section('title', 'Purchase Orders') + +@section('content') + + +
+ + + + + + + + + + + + + + @forelse($orders as $order) + + + + + + + + + + @empty + + + + @endforelse + +
PO #SupplierDateExpected DeliveryTotal AmountStatusActions
{{ $order->po_number ?? 'PO-' . str_pad($order->id, 5, '0', STR_PAD_LEFT) }}{{ $order->supplier->name ?? '' }}{{ $order->po_date ? \Carbon\Carbon::parse($order->po_date)->format('d M Y') : '' }}{{ $order->expected_delivery_date ? \Carbon\Carbon::parse($order->expected_delivery_date)->format('d M Y') : '' }}{{ number_format($order->total_amount, 2) }} + @php + $badgeClass = match($order->status ?? 'draft') { + 'draft' => 'badge-gray', + 'sent' => 'badge-blue', + 'received' => 'badge-green', + 'cancelled' => 'badge-red', + default => 'badge-gray', + }; + @endphp + {{ ucfirst($order->status ?? 'draft') }} + +
+ View + Edit +
+ @csrf + @method('DELETE') + +
+
+
No purchase orders found.
+
+ +@if($orders->hasPages()) +
{{ $orders->links() }}
+@endif +@endsection diff --git a/resources/views/purchase/orders/show.blade.php b/resources/views/purchase/orders/show.blade.php new file mode 100644 index 0000000..9e06142 --- /dev/null +++ b/resources/views/purchase/orders/show.blade.php @@ -0,0 +1,152 @@ +@extends('layouts.app') + +@section('title', 'Purchase Order') + +@section('content') + + +
+ +
+

Order Information

+
+
+
PO Number
+
{{ $order->po_number ?? 'PO-' . str_pad($order->id, 5, '0', STR_PAD_LEFT) }}
+
+
+
Status
+
+ @php + $badgeClass = match($order->status ?? 'draft') { + 'draft' => 'badge-gray', + 'sent' => 'badge-blue', + 'received' => 'badge-green', + 'cancelled' => 'badge-red', + default => 'badge-gray', + }; + @endphp + {{ ucfirst($order->status ?? 'draft') }} +
+
+
+
PO Date
+
{{ $order->po_date ? \Carbon\Carbon::parse($order->po_date)->format('d M Y') : '-' }}
+
+
+
Expected Delivery
+
{{ $order->expected_delivery_date ? \Carbon\Carbon::parse($order->expected_delivery_date)->format('d M Y') : '-' }}
+
+ @if($order->notes) +
+
Notes
+
{{ $order->notes }}
+
+ @endif +
+
+ + +
+

Supplier

+ @if($order->supplier) +
+
+
Name
+
{{ $order->supplier->name }}
+
+
+
Contact
+
{{ $order->supplier->contact_person }}
+
+
+
Email
+
{{ $order->supplier->email }}
+
+
+
Phone
+
{{ $order->supplier->phone }}
+
+
+ @endif +
+
+ + +
+
+

Order Items

+
+ + + + + + + + + + + @forelse($order->items as $item) + + + + + + + @empty + + @endforelse + + + + + + + +
ItemQuantityRateTotal
{{ $item->item->item_name ?? $item->item_name }}{{ number_format($item->quantity, 2) }}{{ number_format($item->rate, 2) }}{{ number_format($item->total, 2) }}
No items.
Total Amount{{ number_format($order->total_amount, 2) }}
+
+ + +@if($order->goodsReceiptNotes && $order->goodsReceiptNotes->count()) +
+
+

Goods Receipt Notes

+
+ + + + + + + + + + + + @foreach($order->goodsReceiptNotes as $grn) + + + + + + + + @endforeach + +
GRN #WarehouseReceived DateStatusActions
{{ $grn->grn_number ?? 'GRN-' . str_pad($grn->id, 5, '0', STR_PAD_LEFT) }}{{ $grn->warehouse->name ?? '' }}{{ $grn->received_date ? \Carbon\Carbon::parse($grn->received_date)->format('d M Y') : '' }}{{ ucfirst($grn->status ?? 'received') }}View
+
+@endif +@endsection diff --git a/resources/views/purchase/payments/create.blade.php b/resources/views/purchase/payments/create.blade.php new file mode 100644 index 0000000..13bc937 --- /dev/null +++ b/resources/views/purchase/payments/create.blade.php @@ -0,0 +1,78 @@ +@extends('layouts.app') + +@section('title', 'Record Supplier Payment') + +@section('content') +
+

Record Supplier Payment

+

Payments / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/purchase/payments/index.blade.php b/resources/views/purchase/payments/index.blade.php new file mode 100644 index 0000000..ca514c0 --- /dev/null +++ b/resources/views/purchase/payments/index.blade.php @@ -0,0 +1,50 @@ +@extends('layouts.app') + +@section('title', 'Supplier Payments') + +@section('content') + + +
+ + + + + + + + + + + + + @forelse($payments as $payment) + + + + + + + + + @empty + + + + @endforelse + +
Invoice #SupplierPayment DateAmountMethodReference
{{ $payment->supplierInvoice->invoice_number ?? '-' }}{{ $payment->supplierInvoice->supplier->name ?? '' }}{{ $payment->payment_date ? \Carbon\Carbon::parse($payment->payment_date)->format('d M Y') : '' }}{{ number_format($payment->amount, 2) }}{{ str_replace('_', ' ', $payment->payment_method) }}{{ $payment->reference_number ?? '-' }}
No payments recorded.
+
+ +@if($payments->hasPages()) +
{{ $payments->links() }}
+@endif +@endsection diff --git a/resources/views/purchase/pipeline/_table.blade.php b/resources/views/purchase/pipeline/_table.blade.php new file mode 100644 index 0000000..b345669 --- /dev/null +++ b/resources/views/purchase/pipeline/_table.blade.php @@ -0,0 +1,62 @@ +
+ + + + + + + + + + + + + + + @foreach($rows as $pr) + @php + $stageIdx = $stages->stageIndex($pr->stage ?? 'draft'); + $total = count(\App\Services\PurchaseStageService::STAGES); + $pct = $total > 1 ? round(($stageIdx / ($total - 1)) * 100) : 100; + $isDone = $pr->stage === 'complete'; + @endphp + + + + + + + + + + + @endforeach + +
Request #Project / DescriptionDepartmentRequested ByStageProgressDate
{{ $pr->request_number }} +
+ {{ $pr->project_name ?: '—' }} +
+ @if($pr->remarks) +
+ {{ Str::limit($pr->remarks, 50) }} +
+ @endif +
{{ $pr->department ?: '—' }}{{ $pr->requested_by_name ?? $pr->requestedBy?->name ?? '—' }} + + {{ $stages->stageLabel($pr->stage) }} + + +
+
+
+
{{ $pct }}%
+
+ {{ $pr->date ? \Carbon\Carbon::parse($pr->date)->format('d M Y') : '—' }} + + + + +
+
diff --git a/resources/views/purchase/pipeline/index.blade.php b/resources/views/purchase/pipeline/index.blade.php new file mode 100644 index 0000000..a0ec50e --- /dev/null +++ b/resources/views/purchase/pipeline/index.blade.php @@ -0,0 +1,79 @@ +@extends('layouts.app') + +@section('title', 'Purchase Pipeline') + +@section('content') + + +
+
+

Purchase Pipeline

+

Track every purchase from request to payment

+
+ + + New Request + +
+ +{{-- Tabs --}} +
+ + +
+ +{{-- Active tab --}} +
+ @if($active->isEmpty()) +
+
+
No active pipelines
+
All purchases are complete, or create a new request.
+
+ @else + @include('purchase.pipeline._table', ['rows' => $active, 'stages' => $stages]) + @endif +
+ +{{-- Completed tab --}} +
+ @if($completed->isEmpty()) +
+
📋
+
No completed purchases yet
+
+ @else + @include('purchase.pipeline._table', ['rows' => $completed, 'stages' => $stages]) + @endif +
+ + +@endsection diff --git a/resources/views/purchase/pipeline/show.blade.php b/resources/views/purchase/pipeline/show.blade.php new file mode 100644 index 0000000..a3dfc60 --- /dev/null +++ b/resources/views/purchase/pipeline/show.blade.php @@ -0,0 +1,885 @@ +@extends('layouts.app') + +@section('title', 'Pipeline — ' . $pr->request_number) + +@section('content') + + +{{-- Breadcrumb --}} +
+ + + + + Purchase Pipeline + +
+ +@php + $stageIdx = $stages->stageIndex($pr->stage); + $allStages = \App\Services\PurchaseStageService::STAGES; + $total = count($allStages); + $pct = $total > 1 ? round(($stageIdx / ($total - 1)) * 100) : 100; + $isDone = $pr->stage === 'complete'; + $pendingInv = $pr->rfqInvitations->where('status', 'pending'); + $sentInv = $pr->rfqInvitations->where('status', '!=', 'pending'); + $selectedIds = $pr->rfqInvitations->pluck('supplier_id')->toArray(); +@endphp + +{{-- Header card --}} +
+
+
+
+

{{ $pr->request_number }}

+ + {{ $stages->stageLabel($pr->stage) }} + +
+
+ @if($pr->project_name) 📁 {{ $pr->project_name }} @endif + @if($pr->department) 🏢 {{ $pr->department }} @endif + @if($pr->requested_by_name ?? $pr->requestedBy) 👤 {{ $pr->requested_by_name ?? $pr->requestedBy->name }} @endif + @if($pr->date) 📅 {{ \Carbon\Carbon::parse($pr->date)->format('d M Y') }} @endif +
+
+
+ + ✏️ Edit Request + + + View Full Request → + +
+
+
+
+
+
+ +{{-- Two-column layout --}} +
+ + {{-- Timeline --}} +
+

Pipeline Stages

+
+ @foreach($allStages as $i => $stage) + @php + $done = $i < $stageIdx; + $current = $i === $stageIdx; + $isLast = $i === $total - 1; + @endphp +
+ + {{-- Dot + line --}} +
+
+ @if($done) + + + + @endif +
+ @if(!$isLast) +
+ @endif +
+ + {{-- Stage content --}} +
+
+
+
+ {{ $stages->stageLabel($stage) }} +
+ + @if($done || $current) +
+ @if($stage === 'draft') + Created by {{ $pr->requested_by_name ?? $pr->requestedBy?->name ?? '—' }} + @if($pr->created_at) · {{ $pr->created_at->format('d M Y') }} @endif + @elseif($stage === 'gm_approval') + @if($pr->signature) + Signed by {{ $pr->signature->signedBy?->name ?? '—' }} · {{ $pr->signature->signed_at?->format('d M Y') }} + @elseif($current) + Awaiting GM signature + @endif + @elseif($stage === 'rfq') + @if($pr->rfqInvitations->count()) + {{ $pr->rfqInvitations->count() }} supplier(s) selected + @if($pendingInv->count()) · {{ $pendingInv->count() }} unsent @endif + @elseif($current) + Select suppliers to receive quote requests + @endif + @elseif($stage === 'quoting') + {{ $pr->supplierQuotes->count() }} quote(s) received · {{ $sentInv->count() }} invited + @elseif($stage === 'comparison') + {{ $pr->supplierQuotes->count() }} quote(s) ready to compare + @elseif($stage === 'lpo' && $pr->awardedQuote) + Awarded to {{ $pr->awardedQuote->supplier->name }} + @endif +
+ @endif +
+ + {{-- Action buttons per stage --}} + @if($current) + @if($stage === 'draft') + + + @elseif($stage === 'gm_approval') +
+ + +
+ + @elseif($stage === 'rfq') +
+ + @if($pendingInv->count() > 0) +
+ @csrf + +
+ @endif +
+ + @elseif($stage === 'quoting') + + View Quotes ({{ $pr->supplierQuotes->count() }}) → + + + @elseif($stage === 'comparison') + + Compare & Award → + + + @elseif($stage === 'lpo') + + Issue LPO → + + + @elseif($stage === 'receiving') + + Record GRN → + + + @elseif($stage === 'payment') + + Issue Payment → + + @endif + @endif +
+
+
+ @endforeach +
+
+ + {{-- Info sidebar --}} +
+ + {{-- Request details --}} +
+

Request Details

+
+ @if($pr->location) +
+
Location
+
{{ $pr->location }}
+
+ @endif + @if($pr->required_date_text) +
+
Required By
+
{{ $pr->required_date_text }}
+
+ @endif + @if($pr->verified_by_name) +
+
Verified By
+
{{ $pr->verified_by_name }}
+
+ @endif +
+
Status
+
{{ ucfirst($pr->status ?? '—') }}
+
+
+
+ + {{-- Selected suppliers (rfq stage+) --}} + @if($pr->rfqInvitations->isNotEmpty()) +
+

+ Suppliers ({{ $pr->rfqInvitations->count() }}) +

+
+ @foreach($pr->rfqInvitations as $inv) + @php + $statusMap = [ + 'pending' => ['bg'=>'#f1f5f9','fg'=>'#64748b','label'=>'Pending'], + 'sent' => ['bg'=>'#dbeafe','fg'=>'#1d4ed8','label'=>'Sent'], + 'opened' => ['bg'=>'#e0e7ff','fg'=>'#3730a3','label'=>'Opened'], + 'submitted' => ['bg'=>'#dcfce7','fg'=>'#15803d','label'=>'Submitted'], + 'declined' => ['bg'=>'#fee2e2','fg'=>'#991b1b','label'=>'Declined'], + ]; + $sc = $statusMap[$inv->status] ?? $statusMap['pending']; + @endphp +
+
+
{{ $inv->supplier->name }}
+ @if($inv->channel !== 'email') +
{{ ucfirst($inv->channel) }}
+ @endif +
+
+ @if($inv->status === 'pending' && in_array($pr->stage, ['rfq','quoting'])) + {{-- WhatsApp link for pending --}} + @if($inv->supplier->phone) + WA + @endif + @endif + + {{ $sc['label'] }} + +
+
+ @endforeach +
+
+ @endif + + {{-- Quotes summary --}} + @if($pr->supplierQuotes->isNotEmpty()) +
+

+ Quotes ({{ $pr->supplierQuotes->count() }}) +

+
+ @foreach($pr->supplierQuotes->sortBy('total_amount') as $quote) +
+
{{ $quote->supplier->name }}
+
+ {{ number_format($quote->total_amount, 2) }} + @if($quote->is_awarded) + Awarded + @endif +
+
+ @endforeach +
+ + View All Quotes → + +
+ @endif + +
+
+ +{{-- ============================================================ + GM SIGNATURE MODAL (always in DOM so the button always works) + ============================================================ --}} + + +{{-- ============================================================ + SELECT SUPPLIERS MODAL + ============================================================ --}} + + + +@endsection diff --git a/resources/views/purchase/quotes/compare.blade.php b/resources/views/purchase/quotes/compare.blade.php new file mode 100644 index 0000000..85ecbbe --- /dev/null +++ b/resources/views/purchase/quotes/compare.blade.php @@ -0,0 +1,179 @@ +@extends('layouts.app') + +@section('title', 'Compare Quotes — ' . $request->request_number) + +@section('content') +
+
+ +
+
+
Quote Comparison
+
{{ $request->request_number }}
+ @if($request->project_name) +
{{ $request->project_name }}
+ @endif +
+ + ← Back to Quotes + +
+ +
+ @if($quotes->isEmpty()) +

No quotes submitted yet.

+ @else + + @php + $awardedQuote = $request->awardedQuote; + @endphp + + + + + + @foreach($quotes as $quote) + + @endforeach + + + + {{-- Per-item rows --}} + @foreach($items as $i => $reqItem) + @php + $rowPrices = $quotes->map(fn($q) => optional($q->items->get($i))->unit_price)->filter()->values(); + $minPrice = $rowPrices->count() ? $rowPrices->min() : null; + @endphp + + + @foreach($quotes as $q) + @php $qItem = $q->items->get($i); $isMin = $qItem && $minPrice !== null && (float)$qItem->unit_price === (float)$minPrice && $rowPrices->count() > 1; @endphp + + @endforeach + + @endforeach + + {{-- Grand totals --}} + @php $minTotal = $quotes->min('total_amount'); @endphp + + + @foreach($quotes as $quote) + @php $isCheapest = (float)$quote->total_amount === (float)$minTotal && $quotes->count() > 1; @endphp + + @endforeach + + + {{-- Lead time --}} + + + @foreach($quotes as $quote) + + @endforeach + + + {{-- Payment terms --}} + + + @foreach($quotes as $quote) + + @endforeach + + + {{-- Award buttons --}} + @if(!$awardedQuote) + + + @foreach($quotes as $quote) + + @endforeach + + @else + + + + @endif + +
Item + {{ $quote->supplier->name }} + @if($quote->is_awarded) +
✓ AWARDED
+ @endif +
+ {{ $reqItem->description }} +
Qty: {{ $reqItem->quantity }} {{ $reqItem->unit }}
+
+ @if($qItem) +
+ @if($isMin)LOWEST@endif + BD {{ number_format($qItem->unit_price, 3) }} +
+
BD {{ number_format($qItem->total_price, 3) }}
+ @else + + @endif +
Grand Total + BD {{ number_format($quote->total_amount, 3) }} +
Lead Time + {{ $quote->lead_time_days !== null ? $quote->lead_time_days.' days' : '—' }} +
Payment Terms + {{ $quote->payment_terms ?: '—' }} +
Award to: + +
+ ✓ Awarded to {{ $awardedQuote->supplier->name }} on {{ $awardedQuote->awarded_at?->format('d M Y') }} +
+ @endif +
+
+
+ +{{-- Award modal --}} + + + +@endsection diff --git a/resources/views/purchase/quotes/index.blade.php b/resources/views/purchase/quotes/index.blade.php new file mode 100644 index 0000000..14bc4e5 --- /dev/null +++ b/resources/views/purchase/quotes/index.blade.php @@ -0,0 +1,104 @@ +@extends('layouts.app') + +@section('title', 'Quotes — ' . $request->request_number) + +@section('content') +
+
+ +
+
+
Supplier Quotes
+
{{ $request->request_number }}
+
+ @if($quotes->count() >= 1) + + Compare All → + + @endif +
+ +
+ + @if($quotes->isEmpty()) +
+
📬
+
No quotes yet
+
Waiting for suppliers to submit their quotes via the private links.
+
+ @else +
+ @foreach($quotes->sortBy('total_amount') as $quote) + @php $isLowest = $quote->total_amount == $quotes->min('total_amount') && $quotes->count() > 1; @endphp +
+
+
+
{{ $quote->supplier->name }}
+
+ Lead time: {{ $quote->lead_time_days !== null ? $quote->lead_time_days.' days' : '—' }} + @if($quote->payment_terms) · {{ $quote->payment_terms }} @endif +
+
Submitted {{ $quote->submitted_at->format('d M Y, H:i') }}
+
+
+
BD {{ number_format($quote->total_amount, 3) }}
+ @if($isLowest && $quotes->count() > 1) +
LOWEST PRICE
+ @endif + @if($quote->is_awarded) +
✓ Awarded
+ @endif +
+
+ + @if($quote->notes) +
+ {{ $quote->notes }} +
+ @endif + + {{-- Item breakdown --}} +
+ View item breakdown ({{ $quote->items->count() }} items) +
+ + + + + + + + + + + @foreach($quote->items as $qi) + + + + + + + @endforeach + +
ItemQtyUnit PriceTotal
{{ $qi->description }}{{ $qi->quantity }}BD {{ number_format($qi->unit_price, 3) }}BD {{ number_format($qi->total_price, 3) }}
+
+
+
+ @endforeach +
+ + @if(!$request->awardedQuote && $quotes->count() >= 1) +
+
Ready to pick a winner?
+ + Open Comparison Table → + +
+ @endif + @endif +
+
+
+@endsection diff --git a/resources/views/purchase/requests/create.blade.php b/resources/views/purchase/requests/create.blade.php new file mode 100644 index 0000000..5dc8e0b --- /dev/null +++ b/resources/views/purchase/requests/create.blade.php @@ -0,0 +1,186 @@ +@extends('layouts.app') + +@section('title', 'New Material Purchase Request') + +@section('content') +
+

New Material Purchase Request

+

Purchase Requests / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+ @csrf + + {{-- Header Details --}} +
+

Project / Department Details

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + {{-- Material Items --}} +
+
+

Material Details

+ +
+ +
+ + + + + + + + + + + + + + @php $oldItems = old('items', [[]]); @endphp + @foreach($oldItems as $idx => $oldItem) + + + + + + + + + + @endforeach + +
S.NoDescription of Material *UnitQty Required *Purpose / UseRequired Date
{{ $idx + 1 }} + + + + + + + + + + + +
+
+
+ + {{-- Remarks --}} +
+ + +
+ +
+ + Cancel +
+
+ + +@endsection diff --git a/resources/views/purchase/requests/edit.blade.php b/resources/views/purchase/requests/edit.blade.php new file mode 100644 index 0000000..e8e67c1 --- /dev/null +++ b/resources/views/purchase/requests/edit.blade.php @@ -0,0 +1,212 @@ +@extends('layouts.app') + +@section('title', 'Edit Purchase Request') + +@section('content') +
+

Edit Purchase Request

+

+ Purchase Requests / + {{ $purchaseRequest->request_number }} / + Edit +

+
+ +@if($errors->any()) +
+ +
+@endif + +
+ @csrf + @method('PUT') + + {{-- Header Details --}} +
+

Project / Department Details

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + {{-- Material Items --}} +
+
+

Material Details

+ +
+ +
+ + + + + + + + + + + + + + @php + $existingItems = old('items') + ? collect(old('items'))->map(fn($i) => (object)$i) + : $purchaseRequest->items; + @endphp + @foreach($existingItems as $idx => $item) + + + + + + + + + + @endforeach + +
S.NoDescription of Material *UnitQty Required *Purpose / UseRequired Date
{{ $idx + 1 }} + + + + + + + + + + + +
+
+
+ + {{-- Remarks --}} +
+ + +
+ +
+ + Cancel +
+
+ + +@endsection diff --git a/resources/views/purchase/requests/index.blade.php b/resources/views/purchase/requests/index.blade.php new file mode 100644 index 0000000..986bc04 --- /dev/null +++ b/resources/views/purchase/requests/index.blade.php @@ -0,0 +1,87 @@ +@extends('layouts.app') + +@section('title', 'Purchase Requests') + +@section('content') + + +
+ + + + + + + + + + + + + + + @forelse($requests as $request) + + + + + + + + + + + @empty + + + + @endforelse + +
Request #DateDepartmentItemQuantityStatusRequested ByActions
{{ $request->request_number ?? '#' . $request->id }}{{ $request->date ? $request->date->format('d M Y') : '' }}{{ $request->department }}{{ $request->item->item_name ?? $request->item_name }}{{ $request->quantity }} {{ $request->unit_of_measure }} + @php + $badgeClass = match($request->status) { + 'pending' => 'badge-yellow', + 'approved' => 'badge-green', + 'rejected' => 'badge-red', + 'ordered' => 'badge-blue', + default => 'badge-gray', + }; + @endphp + {{ ucfirst($request->status) }} + {{ $request->requestedBy->name ?? $request->requested_by }} +
+ @if($request->status === 'pending') +
+ @csrf + @method('PATCH') + +
+
+ @csrf + @method('PATCH') + +
+ @endif + Edit +
+ @csrf + @method('DELETE') + +
+
+
No purchase requests found.
+
+ +@if($requests->hasPages()) +
{{ $requests->links() }}
+@endif +@endsection diff --git a/resources/views/purchase/requests/show.blade.php b/resources/views/purchase/requests/show.blade.php new file mode 100644 index 0000000..58d35ad --- /dev/null +++ b/resources/views/purchase/requests/show.blade.php @@ -0,0 +1,133 @@ +@extends('layouts.app') + +@section('title', 'MPR ' . $purchaseRequest->request_number) + +@section('content') +
+
+

{{ $purchaseRequest->request_number }}

+

Purchase Requests / {{ $purchaseRequest->request_number }}

+
+
+ Print MPR Form + @if($purchaseRequest->status === 'pending') + Edit +
+ @csrf @method('PATCH') + +
+
+ @csrf @method('PATCH') + +
+ @endif +
+
+ +{{-- Status Badge --}} +
+ @php + $badgeClass = match($purchaseRequest->status) { + 'pending' => 'badge-yellow', + 'approved' => 'badge-green', + 'rejected' => 'badge-red', + 'ordered' => 'badge-blue', + default => 'badge-gray', + }; + @endphp + Status: + {{ ucfirst($purchaseRequest->status) }} +
+ +{{-- Header Info --}} +
+

Project / Department Details

+
+
+

MPR Number

+

{{ $purchaseRequest->request_number }}

+
+
+

Date

+

{{ $purchaseRequest->date->format('d-m-Y') }}

+
+
+

Project / Site Name

+

{{ $purchaseRequest->project_name ?? '—' }}

+
+
+

Requested By

+

{{ $purchaseRequest->requested_by_name ?? $purchaseRequest->requestedBy?->name ?? '—' }}

+
+
+

Required Date

+

{{ $purchaseRequest->required_date_text ?? '—' }}

+
+
+

Location / Site

+

{{ $purchaseRequest->location ?? '—' }}

+
+ @if($purchaseRequest->department) +
+

Department

+

{{ $purchaseRequest->department }}

+
+ @endif + @if($purchaseRequest->remarks) +
+

Remarks

+

{{ $purchaseRequest->remarks }}

+
+ @endif +
+
+ +{{-- Material Items --}} +
+

Material Details

+
+ + + + + + + + + + + + + @foreach($purchaseRequest->items as $i => $item) + + + + + + + + + @endforeach + +
S.NoDescription of MaterialUnitQty RequiredPurpose / UseRequired Date
{{ $i + 1 }}{{ $item->description }}{{ $item->unit ?? '—' }}{{ number_format($item->quantity_required, 2) }}{{ $item->purpose_use ?? '—' }}{{ $item->required_date ? $item->required_date->format('d-m-Y') : '—' }}
+
+
+ +{{-- Approval Info --}} +@if($purchaseRequest->approvedBy) +
+

Approval Info

+
+
+

Approved By

+

{{ $purchaseRequest->approvedBy->name }}

+
+
+

Approved At

+

{{ $purchaseRequest->approved_at?->format('d M Y, H:i') ?? '—' }}

+
+
+
+@endif +@endsection diff --git a/resources/views/purchase/rfq/show.blade.php b/resources/views/purchase/rfq/show.blade.php new file mode 100644 index 0000000..756400d --- /dev/null +++ b/resources/views/purchase/rfq/show.blade.php @@ -0,0 +1,156 @@ +@extends('layouts.app') + +@section('title', 'Send RFQ — ' . $request->request_number) + +@section('content') +
+
+ + +
+
Send RFQ Invitations
+
{{ $request->request_number }}
+ @if($request->project_name) +
{{ $request->project_name }}
+ @endif +
+ +
+ + {{-- Already invited --}} + @if($invitations->count()) +
+
Already Invited
+
+ @foreach($invitations as $inv) + @php + $statusColors = [ + 'sent' => ['bg'=>'#eff6ff','border'=>'#bfdbfe','color'=>'#1d4ed8'], + 'opened' => ['bg'=>'#fffbeb','border'=>'#fde68a','color'=>'#92400e'], + 'submitted' => ['bg'=>'#f0fdf4','border'=>'#bbf7d0','color'=>'#15803d'], + 'declined' => ['bg'=>'#fef2f2','border'=>'#fecaca','color'=>'#dc2626'], + 'pending' => ['bg'=>'#f8fafc','border'=>'#e2e8f0','color'=>'#64748b'], + ]; + $sc = $statusColors[$inv->status] ?? $statusColors['pending']; + @endphp +
+
+
{{ $inv->supplier->name }}
+
+ via {{ ucfirst($inv->channel) }} + @if($inv->sent_at) · sent {{ $inv->sent_at->format('d M Y') }} @endif +
+
+
+ @if(in_array($inv->channel, ['whatsapp', 'both'])) + @php $wsLink = app(\App\Services\RfqInvitationService::class)->whatsappLink($inv); @endphp + + WhatsApp + + @endif + + {{ ucfirst($inv->status) }} + +
+
+ @endforeach +
+
+ @endif + + {{-- Invite more suppliers --}} +
+ {{ $invitations->count() ? 'Add More Suppliers' : 'Select Suppliers' }} +
+ + + +
+ @csrf + + +
+ @php $alreadyIds = $invitations->pluck('supplier_id')->toArray(); @endphp + @forelse($suppliers as $supplier) + @php $already = in_array($supplier->id, $alreadyIds); @endphp +
+ + {{-- Channel selector (hidden until checked) --}} + +
+ @empty +

No active suppliers found. Add suppliers first.

+ @endforelse +
+ + +
+
+
+
+ + +@endsection diff --git a/resources/views/purchase/signature/show.blade.php b/resources/views/purchase/signature/show.blade.php new file mode 100644 index 0000000..7201297 --- /dev/null +++ b/resources/views/purchase/signature/show.blade.php @@ -0,0 +1,122 @@ +@extends('layouts.app') + +@section('title', 'GM Signature — ' . $request->request_number) + +@section('content') +
+
+ + +
+
GM Digital Signature
+
{{ $request->request_number }}
+ @if($request->project_name) +
{{ $request->project_name }}
+ @endif +
+ +
+ + @if($request->signature) + +
+ +
+ Signed by {{ $request->signature->signedBy->name }} + on {{ $request->signature->signed_at->format('d M Y, H:i') }} +
+
+ + ← Back to Pipeline + + + @else + +

+ Draw your signature below using your mouse or touch screen to approve this purchase request. +

+ +
+ + +
+ Sign here +
+
+ +
+ + +
+ +
+ @csrf + +
+ +

+ By signing, you approve this purchase request. This action is recorded with your name, timestamp, and IP address. +

+ @endif + +
+
+
+ + +@endsection diff --git a/resources/views/purchase/suppliers/create.blade.php b/resources/views/purchase/suppliers/create.blade.php new file mode 100644 index 0000000..3cdeebc --- /dev/null +++ b/resources/views/purchase/suppliers/create.blade.php @@ -0,0 +1,71 @@ +@extends('layouts.app') + +@section('title', 'Add Supplier') + +@section('content') +
+

Add Supplier

+

Suppliers / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/purchase/suppliers/edit.blade.php b/resources/views/purchase/suppliers/edit.blade.php new file mode 100644 index 0000000..2534c27 --- /dev/null +++ b/resources/views/purchase/suppliers/edit.blade.php @@ -0,0 +1,72 @@ +@extends('layouts.app') + +@section('title', 'Edit Supplier') + +@section('content') +
+

Edit Supplier

+

Suppliers / Edit

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf + @method('PUT') +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ is_active) ? 'checked' : '' }} + class="h-4 w-4 text-blue-600 border-gray-300 rounded"> + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/purchase/suppliers/index.blade.php b/resources/views/purchase/suppliers/index.blade.php new file mode 100644 index 0000000..69bdee8 --- /dev/null +++ b/resources/views/purchase/suppliers/index.blade.php @@ -0,0 +1,1114 @@ +@extends('layouts.app') + +@section('title', 'Suppliers') + +@section('content') + +{{-- ── Page Header ─────────────────────────────────────────────────────────── --}} + + +{{-- ── Stat Cards ───────────────────────────────────────────────────────────── --}} +
+ +
+
+ + + +
+
+
{{ $stats['total'] }}
+
Total Suppliers
+
+
+ +
+
+ + + +
+
+
{{ $stats['active'] }}
+
Active
+
+
+ +
+
+ + + +
+
+
{{ $stats['inactive'] }}
+
Inactive
+
+
+ +
+
+ + + +
+
+
{{ $stats['categories'] }}
+
Categories
+
+
+ +
+ +{{-- ── Search + Count bar ───────────────────────────────────────────────────── --}} +
+
+ + + + +
+
+ {{ $stats['total'] }} suppliers +
+
+ +{{-- ── Table ────────────────────────────────────────────────────────────────── --}} +
+ + + + + + + + + + + + + + + @forelse($suppliers as $i => $supplier) + + + {{-- Code --}} + + + {{-- Company + website --}} + + + {{-- Category --}} + + + {{-- Contact person + emails stacked --}} + + + {{-- Phones stacked --}} + + + {{-- Address --}} + + + {{-- Tax + Credit stacked --}} + + + {{-- Status --}} + + + + @empty + + + + @endforelse + +
CodeCompanyCategoryContact & EmailPhone / WhatsAppAddressTax / CreditStatus
+ {{ $supplier->supplier_code ?: '—' }} + +
{{ $supplier->name }}
+ @if($supplier->website) + + {{ parse_url($supplier->website, PHP_URL_HOST) ?: $supplier->website }} + + @endif +
+ @if($supplier->category) + + {{ $supplier->category }} + + @else + + @endif + + @if($supplier->contact_person) +
{{ $supplier->contact_person }}
+ @endif + @if($supplier->email) + {{ $supplier->email }} + @endif + @if($supplier->secondary_email) + {{ $supplier->secondary_email }} + @endif + @if(!$supplier->contact_person && !$supplier->email) + + @endif +
+ @if($supplier->phone) + + {{ $supplier->phone }} + + @endif + @if($supplier->phone2) + + {{ $supplier->phone2 }} + + @endif + @if($supplier->whatsapp) + + + + + {{ $supplier->whatsapp }} + + @endif + @if(!$supplier->phone && !$supplier->phone2 && !$supplier->whatsapp) + + @endif + + {{ $supplier->address ?: '—' }} + + @if($supplier->tax_number) +
{{ $supplier->tax_number }}
+ @endif + @if(in_array(strtolower($supplier->credit_terms ?? ''), ['y','yes'])) +
+ Credit{{ $supplier->credit_days ? ' · '.$supplier->credit_days.'d' : '' }} +
+ @elseif(!$supplier->tax_number) + + @endif +
+ @if($supplier->is_active) + Active + @else + Inactive + @endif +
No suppliers found.
+
+ +{{-- No-results message (shown by JS) --}} + + +{{-- ═══════════ Row Context Dropdown ═══════════ --}} + + +{{-- ═══════════ Supplier View / Edit Modal ═══════════ --}} + + @foreach($categories as $cat) + + + + +{{-- ═══════════ Delete Confirmation Modal ═══════════ --}} + + +{{-- ═══════════ Import Modal ═══════════ --}} + + + + + + +@endsection diff --git a/resources/views/purchase/suppliers/pdf.blade.php b/resources/views/purchase/suppliers/pdf.blade.php new file mode 100644 index 0000000..aca9f22 --- /dev/null +++ b/resources/views/purchase/suppliers/pdf.blade.php @@ -0,0 +1,198 @@ + + + + +Suppliers List + + + + +
+
+
+ + + +
+
+
SteelERP
+
Manufacturing & Trading
+
+
+
+
Supplier Directory
+
Generated: {{ now()->format('d M Y, H:i') }}
+
Total Records: {{ $suppliers->count() }}
+
+
+ +@php + $active = $suppliers->where('is_active', true)->count(); + $inactive = $suppliers->where('is_active', false)->count(); +@endphp + +
+
+
{{ $suppliers->count() }}
+
Total Suppliers
+
+
+
{{ $active }}
+
Active
+
+
+
{{ $inactive }}
+
Inactive
+
+
+ +
+ + + + + + + + + + + + + + @forelse($suppliers as $i => $supplier) + + + + + + + + + + @empty + + + + @endforelse + +
#Supplier NameContact PersonEmailPhoneTax NumberStatus
{{ $i + 1 }}{{ $supplier->name }}{{ $supplier->contact_person ?: '—' }}{{ $supplier->email ?: '—' }}{{ $supplier->phone ?: '—' }}{{ $supplier->tax_number ?: '—' }} + @if($supplier->is_active) + Active + @else + Inactive + @endif +
+ No suppliers found. +
+
+ + + + + diff --git a/resources/views/purchase/suppliers/show.blade.php b/resources/views/purchase/suppliers/show.blade.php new file mode 100644 index 0000000..d29125a --- /dev/null +++ b/resources/views/purchase/suppliers/show.blade.php @@ -0,0 +1,135 @@ +@extends('layouts.app') + +@section('title', $supplier->name) + +@section('content') + + + +
+ + {{-- Identity --}} +
+
+

Company

+ @if($supplier->is_active) + Active + @else + Inactive + @endif +
+
{{ $supplier->name }}
+ @if($supplier->supplier_code) +
{{ $supplier->supplier_code }}
+ @endif + @if($supplier->category) + + {{ $supplier->category }} + + @endif + @if($supplier->website) +
+ + {{ $supplier->website }} + +
+ @endif +
+ + {{-- Contact --}} +
+

Contact

+ @if($supplier->contact_person) +
+
Contact Person
+
{{ $supplier->contact_person }}
+
+ @endif + @if($supplier->email) +
+
Primary Email
+ {{ $supplier->email }} +
+ @endif + @if($supplier->secondary_email) +
+
Secondary Email
+ {{ $supplier->secondary_email }} +
+ @endif + @if($supplier->phone) +
+
Phone 1
+ {{ $supplier->phone }} +
+ @endif + @if($supplier->phone2) +
+
Phone 2
+ {{ $supplier->phone2 }} +
+ @endif + @if($supplier->whatsapp) +
+
WhatsApp
+ + + + + {{ $supplier->whatsapp }} + +
+ @endif +
+ + {{-- Financial --}} +
+

Financial & Address

+ @if($supplier->address) +
+
Address
+
{{ $supplier->address }}
+
+ @endif + @if($supplier->tax_number) +
+
Tax / VAT Number
+
{{ $supplier->tax_number }}
+
+ @endif + @if($supplier->credit_terms) +
+
Credit Terms
+
+ {{ strtolower($supplier->credit_terms) === 'y' ? 'Yes' : $supplier->credit_terms }} + @if($supplier->credit_days) +  ·  {{ $supplier->credit_days }} days + @endif +
+
+ @endif + @if($supplier->remarks) +
+
Remarks
+
{{ $supplier->remarks }}
+
+ @endif +
+ +
+ +@endsection diff --git a/resources/views/rfq/expired.blade.php b/resources/views/rfq/expired.blade.php new file mode 100644 index 0000000..d041fca --- /dev/null +++ b/resources/views/rfq/expired.blade.php @@ -0,0 +1,28 @@ + + + + + + Link Expired + + + +
+
+
Link Expired
+
+ This quote invitation link has expired (valid for 7 days after sending). + Please contact the purchasing team if you still wish to submit a quote. +
+ @if($invitation->expires_at) +
+ Expired on {{ $invitation->expires_at->format('d M Y') }} +
+ @endif +
+ + diff --git a/resources/views/rfq/show.blade.php b/resources/views/rfq/show.blade.php new file mode 100644 index 0000000..de32523 --- /dev/null +++ b/resources/views/rfq/show.blade.php @@ -0,0 +1,131 @@ + + + + + + Quote Request — {{ $purchaseRequest->request_number }} + + + +
+
+
Request for Quotation
+
{{ $purchaseRequest->request_number }}
+ @if($purchaseRequest->project_name) +
{{ $purchaseRequest->project_name }}
+ @endif +
+ +
+

+ Hello {{ $invitation->supplier->name }}, +

+

+ Please enter your unit prices for the items below, then scroll down and submit. This link is private to your company and can only be submitted once. +

+ +
+ @csrf + + {{-- Items table --}} +
+ + + + + + + + + + + + + @foreach($items as $i => $item) + + + + + + + + + @endforeach + + + + + + + +
#DescriptionQtyUnitUnit Price (BD)Total (BD)
{{ $i + 1 }}{{ $item->description }}{{ rtrim(rtrim(number_format((float)$item->quantity, 3), '0'), '.') }}{{ $item->unit ?: '—' }} + +
Grand Total:BD 0.000
+
+ + {{-- Terms --}} +
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+ +

+ Link expires {{ $invitation->expires_at->format('d M Y') }} · One submission only +

+
+
+ + + + diff --git a/resources/views/rfq/submitted.blade.php b/resources/views/rfq/submitted.blade.php new file mode 100644 index 0000000..f28cafb --- /dev/null +++ b/resources/views/rfq/submitted.blade.php @@ -0,0 +1,27 @@ + + + + + + Quote Submitted + + + +
+
+
Quote Submitted!
+
+ Thank you, {{ $invitation->supplier->name }}. Your quote for + {{ $invitation->purchaseRequest->request_number }} has been received + and is under review. You will be contacted if you are selected. +
+
+ Submitted on {{ now()->format('d M Y, H:i') }} +
+
+ + diff --git a/resources/views/sales/customers/create.blade.php b/resources/views/sales/customers/create.blade.php new file mode 100644 index 0000000..08669ff --- /dev/null +++ b/resources/views/sales/customers/create.blade.php @@ -0,0 +1,76 @@ +@extends('layouts.app') + +@section('title', 'Add Customer') + +@section('content') +
+

Add Customer

+

Customers / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/sales/customers/edit.blade.php b/resources/views/sales/customers/edit.blade.php new file mode 100644 index 0000000..d40ad42 --- /dev/null +++ b/resources/views/sales/customers/edit.blade.php @@ -0,0 +1,77 @@ +@extends('layouts.app') + +@section('title', 'Edit Customer') + +@section('content') +
+

Edit Customer

+

Customers / Edit

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf + @method('PUT') +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ is_active) ? 'checked' : '' }} + class="h-4 w-4 text-blue-600 border-gray-300 rounded"> + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/sales/customers/index.blade.php b/resources/views/sales/customers/index.blade.php new file mode 100644 index 0000000..107821c --- /dev/null +++ b/resources/views/sales/customers/index.blade.php @@ -0,0 +1,70 @@ +@extends('layouts.app') + +@section('title', 'Customers') + +@section('content') + + +
+ + + + + + + + + + + + + + @forelse($customers as $customer) + + + + + + + + + + @empty + + + + @endforelse + +
NameContactEmailCredit LimitOutstanding BalanceStatusActions
{{ $customer->name }}{{ $customer->contact_person }}{{ $customer->email }}{{ number_format($customer->credit_limit, 2) }} + {{ number_format($customer->outstanding_balance ?? 0, 2) }} + + @if($customer->is_active) + Active + @else + Inactive + @endif + +
+ Edit +
+ @csrf + @method('DELETE') + +
+
+
No customers found.
+
+ +@if($customers->hasPages()) +
{{ $customers->links() }}
+@endif +@endsection diff --git a/resources/views/sales/delivery-notes/create.blade.php b/resources/views/sales/delivery-notes/create.blade.php new file mode 100644 index 0000000..3667943 --- /dev/null +++ b/resources/views/sales/delivery-notes/create.blade.php @@ -0,0 +1,129 @@ +@extends('layouts.app') + +@section('title', 'New Delivery Note') + +@section('content') +
+

New Delivery Note

+

Delivery Notes / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+ @csrf + +
+

Delivery Details

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+

Items to Deliver

+
+ + + + + + + + + + + + + +
ProductSO QtyDeliver Qty
Select a Sales Order to load items.
+
+
+ +
+ + Cancel +
+
+ + +@endsection diff --git a/resources/views/sales/delivery-notes/edit.blade.php b/resources/views/sales/delivery-notes/edit.blade.php new file mode 100644 index 0000000..e9ad329 --- /dev/null +++ b/resources/views/sales/delivery-notes/edit.blade.php @@ -0,0 +1,57 @@ +@extends('layouts.app') + +@section('title', 'Edit Delivery Note') + +@section('content') +
+

Edit Delivery Note

+

Delivery Notes / Edit

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf + @method('PUT') +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/sales/delivery-notes/index.blade.php b/resources/views/sales/delivery-notes/index.blade.php new file mode 100644 index 0000000..2222c2b --- /dev/null +++ b/resources/views/sales/delivery-notes/index.blade.php @@ -0,0 +1,77 @@ +@extends('layouts.app') + +@section('title', 'Delivery Notes') + +@section('content') + + +
+ + + + + + + + + + + + + + @forelse($deliveryNotes as $dn) + + + + + + + + + + @empty + + + + @endforelse + +
DN #Sales OrderCustomerWarehouseDateStatusActions
{{ $dn->dn_number ?? 'DN-' . str_pad($dn->id, 5, '0', STR_PAD_LEFT) }}{{ $dn->salesOrder->order_number ?? 'SO-' . str_pad($dn->sales_order_id, 5, '0', STR_PAD_LEFT) }}{{ $dn->salesOrder->customer->name ?? '' }}{{ $dn->warehouse->name ?? '' }}{{ $dn->delivery_date ? \Carbon\Carbon::parse($dn->delivery_date)->format('d M Y') : '' }} + @php + $badgeClass = match($dn->status ?? 'pending') { + 'pending' => 'badge-yellow', + 'dispatched' => 'badge-green', + 'cancelled' => 'badge-red', + default => 'badge-gray', + }; + @endphp + {{ ucfirst($dn->status ?? 'pending') }} + +
+ @if($dn->status === 'pending') +
+ @csrf @method('PATCH') + +
+ @endif + Edit +
+ @csrf @method('DELETE') + +
+
+
No delivery notes found.
+
+ +@if($deliveryNotes->hasPages()) +
{{ $deliveryNotes->links() }}
+@endif +@endsection diff --git a/resources/views/sales/invoices/create.blade.php b/resources/views/sales/invoices/create.blade.php new file mode 100644 index 0000000..c76e341 --- /dev/null +++ b/resources/views/sales/invoices/create.blade.php @@ -0,0 +1,128 @@ +@extends('layouts.app') + +@section('title', 'New Sales Invoice') + +@section('content') +
+

New Sales Invoice

+

Sales Invoices / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+ + +@endsection diff --git a/resources/views/sales/invoices/edit.blade.php b/resources/views/sales/invoices/edit.blade.php new file mode 100644 index 0000000..89502d1 --- /dev/null +++ b/resources/views/sales/invoices/edit.blade.php @@ -0,0 +1,81 @@ +@extends('layouts.app') + +@section('title', 'Edit Sales Invoice') + +@section('content') +
+

Edit Sales Invoice

+

Sales Invoices / Edit

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf + @method('PUT') +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+ + +@endsection diff --git a/resources/views/sales/invoices/index.blade.php b/resources/views/sales/invoices/index.blade.php new file mode 100644 index 0000000..11a877d --- /dev/null +++ b/resources/views/sales/invoices/index.blade.php @@ -0,0 +1,77 @@ +@extends('layouts.app') + +@section('title', 'Sales Invoices') + +@section('content') + + +
+ + + + + + + + + + + + + + + + @forelse($invoices as $invoice) + @php $outstanding = $invoice->total_amount - $invoice->paid_amount; @endphp + + + + + + + + + + + + @empty + + + + @endforelse + +
Invoice #CustomerSO #DateTotalPaidOutstandingStatusActions
{{ $invoice->invoice_number }}{{ $invoice->customer->name ?? '' }}{{ $invoice->salesOrder->order_number ?? '-' }}{{ $invoice->invoice_date ? \Carbon\Carbon::parse($invoice->invoice_date)->format('d M Y') : '' }}{{ number_format($invoice->total_amount, 2) }}{{ number_format($invoice->paid_amount, 2) }}{{ number_format($outstanding, 2) }} + @php + $badgeClass = match($invoice->status ?? 'unpaid') { + 'unpaid' => 'badge-red', + 'partial' => 'badge-yellow', + 'paid' => 'badge-green', + default => 'badge-gray', + }; + @endphp + {{ ucfirst($invoice->status ?? 'unpaid') }} + +
+ Receive + Edit +
+ @csrf @method('DELETE') + +
+
+
No invoices found.
+
+ +@if($invoices->hasPages()) +
{{ $invoices->links() }}
+@endif +@endsection diff --git a/resources/views/sales/orders/create.blade.php b/resources/views/sales/orders/create.blade.php new file mode 100644 index 0000000..22abef6 --- /dev/null +++ b/resources/views/sales/orders/create.blade.php @@ -0,0 +1,180 @@ +@extends('layouts.app') + +@section('title', 'New Sales Order') + +@section('content') +
+

New Sales Order

+

Sales Orders / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+ @csrf + + +
+

Order Details

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+

Order Items

+ +
+ +
+ + + + + + + + + + + + + + + + + + + +
ProductQuantityUnit PriceTotal
+ + + + + + + + + +
+
+ +
+
+ Grand Total: 0.00 +
+
+
+ +
+ + Cancel +
+
+ + +@endsection diff --git a/resources/views/sales/orders/edit.blade.php b/resources/views/sales/orders/edit.blade.php new file mode 100644 index 0000000..c074f66 --- /dev/null +++ b/resources/views/sales/orders/edit.blade.php @@ -0,0 +1,62 @@ +@extends('layouts.app') + +@section('title', 'Edit Sales Order') + +@section('content') +
+

Edit Sales Order

+

Sales Orders / Edit

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf + @method('PUT') +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/sales/orders/index.blade.php b/resources/views/sales/orders/index.blade.php new file mode 100644 index 0000000..a2e31c2 --- /dev/null +++ b/resources/views/sales/orders/index.blade.php @@ -0,0 +1,78 @@ +@extends('layouts.app') + +@section('title', 'Sales Orders') + +@section('content') + + +
+ + + + + + + + + + + + + @forelse($orders as $order) + + + + + + + + + @empty + + + + @endforelse + +
Order #CustomerDateTotalStatusActions
{{ $order->order_number ?? 'SO-' . str_pad($order->id, 5, '0', STR_PAD_LEFT) }}{{ $order->customer->name ?? '' }}{{ $order->order_date ? \Carbon\Carbon::parse($order->order_date)->format('d M Y') : '' }}{{ number_format($order->total_amount, 2) }} + @php + $badgeClass = match($order->status ?? 'draft') { + 'draft' => 'badge-gray', + 'confirmed' => 'badge-blue', + 'dispatched' => 'badge-violet', + 'invoiced' => 'badge-green', + 'cancelled' => 'badge-red', + default => 'badge-gray', + }; + @endphp + {{ ucfirst($order->status ?? 'draft') }} + +
+ View + @if($order->status === 'draft') +
+ @csrf @method('PATCH') + +
+ @endif + Edit +
+ @csrf @method('DELETE') + +
+
+
No sales orders found.
+
+ +@if($orders->hasPages()) +
{{ $orders->links() }}
+@endif +@endsection diff --git a/resources/views/sales/orders/show.blade.php b/resources/views/sales/orders/show.blade.php new file mode 100644 index 0000000..1caa66a --- /dev/null +++ b/resources/views/sales/orders/show.blade.php @@ -0,0 +1,175 @@ +@extends('layouts.app') + +@section('title', 'Sales Order') + +@section('content') + + +
+ +
+

Order Details

+
+
+
Order Number
+
{{ $order->order_number ?? 'SO-' . str_pad($order->id, 5, '0', STR_PAD_LEFT) }}
+
+
+
Status
+
+ @php + $badgeClass = match($order->status ?? 'draft') { + 'draft' => 'badge-gray', + 'confirmed' => 'badge-blue', + 'dispatched'=> 'badge-violet', + 'invoiced' => 'badge-green', + default => 'badge-gray', + }; + @endphp + {{ ucfirst($order->status ?? 'draft') }} +
+
+
+
Order Date
+
{{ $order->order_date ? \Carbon\Carbon::parse($order->order_date)->format('d M Y') : '-' }}
+
+
+
Delivery Date
+
{{ $order->delivery_date ? \Carbon\Carbon::parse($order->delivery_date)->format('d M Y') : '-' }}
+
+
+
+ + +
+

Customer

+ @if($order->customer) +
+
Name
{{ $order->customer->name }}
+
Contact
{{ $order->customer->contact_person }}
+
Email
{{ $order->customer->email }}
+
Phone
{{ $order->customer->phone }}
+
+ @endif +
+
+ + +
+
+

Order Items

+
+
+ + + + + + + + + + + @forelse($order->items as $item) + + + + + + + @empty + + @endforelse + + + + + + + +
ProductQuantityUnit PriceTotal
{{ $item->item->item_name ?? '' }}{{ number_format($item->quantity, 2) }}{{ number_format($item->price, 2) }}{{ number_format($item->total, 2) }}
No items.
Total{{ number_format($order->total_amount, 2) }}
+
+
+ + +@if(isset($deliveryNotes) && $deliveryNotes->count()) +
+
+

Delivery Notes

+
+
+ + + + + + + + + + + @foreach($deliveryNotes as $dn) + + + + + + + @endforeach + +
DN #WarehouseDelivery DateStatus
{{ $dn->dn_number ?? 'DN-' . str_pad($dn->id, 5, '0', STR_PAD_LEFT) }}{{ $dn->warehouse->name ?? '' }}{{ $dn->delivery_date ? \Carbon\Carbon::parse($dn->delivery_date)->format('d M Y') : '' }}{{ ucfirst($dn->status ?? 'pending') }}
+
+
+@endif + + +@if(isset($invoices) && $invoices->count()) +
+
+

Invoices

+
+
+ + + + + + + + + + + @foreach($invoices as $invoice) + + + + + + + @endforeach + +
Invoice #DateTotalStatus
{{ $invoice->invoice_number }}{{ $invoice->invoice_date ? \Carbon\Carbon::parse($invoice->invoice_date)->format('d M Y') : '' }}{{ number_format($invoice->total_amount, 2) }}{{ ucfirst($invoice->status ?? 'unpaid') }}
+
+
+@endif +@endsection diff --git a/resources/views/sales/payments/create.blade.php b/resources/views/sales/payments/create.blade.php new file mode 100644 index 0000000..420a80d --- /dev/null +++ b/resources/views/sales/payments/create.blade.php @@ -0,0 +1,78 @@ +@extends('layouts.app') + +@section('title', 'Record Customer Receipt') + +@section('content') +
+

Record Customer Receipt

+

Receipts / New

+
+ +@if($errors->any()) +
+ +
+@endif + +
+
+ @csrf +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/resources/views/sales/payments/index.blade.php b/resources/views/sales/payments/index.blade.php new file mode 100644 index 0000000..e64c5f1 --- /dev/null +++ b/resources/views/sales/payments/index.blade.php @@ -0,0 +1,50 @@ +@extends('layouts.app') + +@section('title', 'Customer Receipts') + +@section('content') + + +
+ + + + + + + + + + + + + @forelse($payments as $payment) + + + + + + + + + @empty + + + + @endforelse + +
CustomerInvoice #DateAmountMethodReference
{{ $payment->salesInvoice->customer->name ?? '' }}{{ $payment->salesInvoice->invoice_number ?? '-' }}{{ $payment->receipt_date ? \Carbon\Carbon::parse($payment->receipt_date)->format('d M Y') : '' }}{{ number_format($payment->amount, 2) }}{{ str_replace('_', ' ', $payment->payment_method) }}{{ $payment->reference_number ?? '-' }}
No receipts recorded.
+
+ +@if($payments->hasPages()) +
{{ $payments->links() }}
+@endif +@endsection diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..b7355d7 --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,277 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

+ + +
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- Light Mode 12 SVG --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{-- Dark Mode 12 SVG --}} + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/routes/auth.php b/routes/auth.php new file mode 100644 index 0000000..3926ecf --- /dev/null +++ b/routes/auth.php @@ -0,0 +1,59 @@ +group(function () { + Route::get('register', [RegisteredUserController::class, 'create']) + ->name('register'); + + Route::post('register', [RegisteredUserController::class, 'store']); + + Route::get('login', [AuthenticatedSessionController::class, 'create']) + ->name('login'); + + Route::post('login', [AuthenticatedSessionController::class, 'store']); + + Route::get('forgot-password', [PasswordResetLinkController::class, 'create']) + ->name('password.request'); + + Route::post('forgot-password', [PasswordResetLinkController::class, 'store']) + ->name('password.email'); + + Route::get('reset-password/{token}', [NewPasswordController::class, 'create']) + ->name('password.reset'); + + Route::post('reset-password', [NewPasswordController::class, 'store']) + ->name('password.store'); +}); + +Route::middleware('auth')->group(function () { + Route::get('verify-email', EmailVerificationPromptController::class) + ->name('verification.notice'); + + Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + + Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store']) + ->middleware('throttle:6,1') + ->name('verification.send'); + + Route::get('confirm-password', [ConfirmablePasswordController::class, 'show']) + ->name('password.confirm'); + + Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']); + + Route::put('password', [PasswordController::class, 'update'])->name('password.update'); + + Route::post('logout', [AuthenticatedSessionController::class, 'destroy']) + ->name('logout'); +}); diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..3a5f597 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,118 @@ +route('dashboard'); +}); + +// Public RFQ portal — no auth required +Route::get('/rfq/{token}', [RfqPortalController::class, 'show'])->name('rfq.show'); +Route::post('/rfq/{token}', [RfqPortalController::class, 'submit'])->name('rfq.submit'); + +Route::middleware(['auth', 'verified'])->group(function () { + Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); + + Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); + Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); + Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); + + // Purchase Module + Route::prefix('purchase')->name('purchase.')->group(function () { + // Pipeline + Route::get('pipeline', [PurchasePipelineController::class, 'index'])->name('pipeline.index'); + Route::get('pipeline/{purchaseRequest}', [PurchasePipelineController::class, 'show'])->name('pipeline.show'); + + // GM Signature + Route::get('requests/{purchaseRequest}/sign', [PurchaseSignatureController::class, 'show'])->name('requests.sign'); + Route::post('requests/{purchaseRequest}/sign', [PurchaseSignatureController::class, 'store'])->name('requests.sign.store'); + + // RFQ + Route::post('requests/{purchaseRequest}/rfq/select', [RfqController::class, 'selectSuppliers'])->name('requests.rfq.select'); + Route::post('requests/{purchaseRequest}/rfq/send-all', [RfqController::class, 'sendAll'])->name('requests.rfq.send-all'); + Route::get('requests/{purchaseRequest}/rfq', [RfqController::class, 'show'])->name('requests.rfq'); + Route::post('requests/{purchaseRequest}/rfq', [RfqController::class, 'store'])->name('requests.rfq.store'); + + // Quotes + Route::get('requests/{purchaseRequest}/quotes', [SupplierQuoteController::class, 'index'])->name('requests.quotes'); + Route::get('requests/{purchaseRequest}/compare', [SupplierQuoteController::class, 'compare'])->name('requests.compare'); + Route::post('requests/{purchaseRequest}/quotes/{quote}/award', [SupplierQuoteController::class, 'award'])->name('requests.quotes.award'); + + Route::post('suppliers/import', [SupplierController::class, 'import'])->name('suppliers.import'); + Route::get('suppliers/template', [SupplierController::class, 'downloadTemplate'])->name('suppliers.template'); + Route::get('suppliers/export-pdf', [SupplierController::class, 'exportPdf'])->name('suppliers.export-pdf'); + Route::resource('suppliers', SupplierController::class); + Route::resource('requests', PurchaseRequestController::class)->parameters(['requests' => 'purchaseRequest']); + Route::patch('requests/{purchaseRequest}/approve', [PurchaseRequestController::class, 'approve'])->name('requests.approve'); + Route::patch('requests/{purchaseRequest}/reject', [PurchaseRequestController::class, 'reject'])->name('requests.reject'); + Route::get('requests/{purchaseRequest}/print', [PurchaseRequestController::class, 'print'])->name('requests.print'); + Route::resource('orders', PurchaseOrderController::class); + Route::resource('grns', GoodsReceiptNoteController::class); + Route::patch('grns/{grn}/confirm', [GoodsReceiptNoteController::class, 'confirm'])->name('grns.confirm'); + Route::resource('invoices', SupplierInvoiceController::class); + Route::resource('payments', SupplierPaymentController::class); + }); + + // Inventory Module + Route::prefix('inventory')->name('inventory.')->group(function () { + Route::post('items/import', [ItemController::class, 'import'])->name('items.import'); + Route::get('items/template', [ItemController::class, 'downloadTemplate'])->name('items.template'); + Route::get('items/export-pdf', [ItemController::class, 'exportPdf'])->name('items.export-pdf'); + Route::resource('items', ItemController::class); + Route::resource('warehouses', WarehouseController::class); + Route::resource('movements', StockMovementController::class); + Route::get('reports/summary', [StockReportController::class, 'summary'])->name('reports.summary'); + Route::get('reports/movement', [StockReportController::class, 'movement'])->name('reports.movement'); + Route::get('reports/low-stock', [StockReportController::class, 'lowStock'])->name('reports.low-stock'); + Route::get('reports/valuation', [StockReportController::class, 'valuation'])->name('reports.valuation'); + }); + + // Production Module + Route::prefix('production')->name('production.')->group(function () { + Route::resource('orders', ProductionOrderController::class); + Route::patch('orders/{order}/start', [ProductionOrderController::class, 'start'])->name('orders.start'); + Route::patch('orders/{order}/complete', [ProductionOrderController::class, 'complete'])->name('orders.complete'); + Route::resource('bom', BillOfMaterialController::class); + Route::resource('material-issues', MaterialIssueController::class); + Route::resource('outputs', ProductionOutputController::class); + }); + + // Sales Module + Route::prefix('sales')->name('sales.')->group(function () { + Route::resource('customers', CustomerController::class); + Route::resource('orders', SalesOrderController::class); + Route::patch('orders/{order}/confirm', [SalesOrderController::class, 'confirm'])->name('orders.confirm'); + Route::resource('delivery-notes', DeliveryNoteController::class); + Route::patch('delivery-notes/{note}/dispatch', [DeliveryNoteController::class, 'dispatch'])->name('delivery-notes.dispatch'); + Route::resource('invoices', SalesInvoiceController::class); + Route::resource('payments', PaymentReceiptController::class); + }); +}); + +require __DIR__.'/auth.php'; diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 0000000..fedb287 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore new file mode 100644 index 0000000..05c4471 --- /dev/null +++ b/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..c29eb1a --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,21 @@ +import defaultTheme from 'tailwindcss/defaultTheme'; +import forms from '@tailwindcss/forms'; + +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', + './storage/framework/views/*.php', + './resources/views/**/*.blade.php', + ], + + theme: { + extend: { + fontFamily: { + sans: ['Figtree', ...defaultTheme.fontFamily.sans], + }, + }, + }, + + plugins: [forms], +}; diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php new file mode 100644 index 0000000..13dcb7c --- /dev/null +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -0,0 +1,54 @@ +get('/login'); + + $response->assertStatus(200); + } + + public function test_users_can_authenticate_using_the_login_screen(): void + { + $user = User::factory()->create(); + + $response = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); + } + + public function test_users_can_not_authenticate_with_invalid_password(): void + { + $user = User::factory()->create(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $this->assertGuest(); + } + + public function test_users_can_logout(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/logout'); + + $this->assertGuest(); + $response->assertRedirect('/'); + } +} diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 0000000..705570b --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,58 @@ +unverified()->create(); + + $response = $this->actingAs($user)->get('/verify-email'); + + $response->assertStatus(200); + } + + public function test_email_can_be_verified(): void + { + $user = User::factory()->unverified()->create(); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + } + + public function test_email_is_not_verified_with_invalid_hash(): void + { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + $this->actingAs($user)->get($verificationUrl); + + $this->assertFalse($user->fresh()->hasVerifiedEmail()); + } +} diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php new file mode 100644 index 0000000..ff85721 --- /dev/null +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -0,0 +1,44 @@ +create(); + + $response = $this->actingAs($user)->get('/confirm-password'); + + $response->assertStatus(200); + } + + public function test_password_can_be_confirmed(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'password', + ]); + + $response->assertRedirect(); + $response->assertSessionHasNoErrors(); + } + + public function test_password_is_not_confirmed_with_invalid_password(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'wrong-password', + ]); + + $response->assertSessionHasErrors(); + } +} diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php new file mode 100644 index 0000000..aa50350 --- /dev/null +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -0,0 +1,73 @@ +get('/forgot-password'); + + $response->assertStatus(200); + } + + public function test_reset_password_link_can_be_requested(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class); + } + + public function test_reset_password_screen_can_be_rendered(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) { + $response = $this->get('/reset-password/'.$notification->token); + + $response->assertStatus(200); + + return true; + }); + } + + public function test_password_can_be_reset_with_valid_token(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $response = $this->post('/reset-password', [ + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('login')); + + return true; + }); + } +} diff --git a/tests/Feature/Auth/PasswordUpdateTest.php b/tests/Feature/Auth/PasswordUpdateTest.php new file mode 100644 index 0000000..ca28c6c --- /dev/null +++ b/tests/Feature/Auth/PasswordUpdateTest.php @@ -0,0 +1,51 @@ +create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); + } + + public function test_correct_password_must_be_provided_to_update_password(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'wrong-password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasErrorsIn('updatePassword', 'current_password') + ->assertRedirect('/profile'); + } +} diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php new file mode 100644 index 0000000..1489d0e --- /dev/null +++ b/tests/Feature/Auth/RegistrationTest.php @@ -0,0 +1,31 @@ +get('/register'); + + $response->assertStatus(200); + } + + public function test_new_users_can_register(): void + { + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); + } +} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php new file mode 100644 index 0000000..8364a84 --- /dev/null +++ b/tests/Feature/ExampleTest.php @@ -0,0 +1,19 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php new file mode 100644 index 0000000..252fdcc --- /dev/null +++ b/tests/Feature/ProfileTest.php @@ -0,0 +1,99 @@ +create(); + + $response = $this + ->actingAs($user) + ->get('/profile'); + + $response->assertOk(); + } + + public function test_profile_information_can_be_updated(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $user->refresh(); + + $this->assertSame('Test User', $user->name); + $this->assertSame('test@example.com', $user->email); + $this->assertNull($user->email_verified_at); + } + + public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => $user->email, + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertNotNull($user->refresh()->email_verified_at); + } + + public function test_user_can_delete_their_account(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->delete('/profile', [ + 'password' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/'); + + $this->assertGuest(); + $this->assertNull($user->fresh()); + } + + public function test_correct_password_must_be_provided_to_delete_account(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->delete('/profile', [ + 'password' => 'wrong-password', + ]); + + $response + ->assertSessionHasErrorsIn('userDeletion', 'password') + ->assertRedirect('/profile'); + + $this->assertNotNull($user->fresh()); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..421b569 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + ], +});