chore: initial commit of existing codebase

This commit is contained in:
Ghassan Yusuf 2026-05-19 12:40:08 +03:00
commit 11e94889b2
292 changed files with 38586 additions and 0 deletions

375
.claude/mcp-server.py Normal file
View File

@ -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")

5
.claude/settings.json Normal file
View File

@ -0,0 +1,5 @@
{
"enabledPlugins": {
"superpowers@claude-plugins-official": true
}
}

18
.editorconfig Normal file
View File

@ -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

65
.env.example Normal file
View File

@ -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}"

11
.gitattributes vendored Normal file
View File

@ -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

24
.gitignore vendored Normal file
View File

@ -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

View File

@ -0,0 +1,169 @@
<h2>Purchase Pipeline — Architecture</h2>
<p class="subtitle">7 stages, 3 new tables, everything else reuses existing models</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:4px;">
<!-- Left: Stage map -->
<div>
<div class="label" style="margin-bottom:12px;">The 7 Stages</div>
<div style="display:flex;flex-direction:column;gap:0;">
<!-- Stage 1 -->
<div style="display:flex;align-items:stretch;gap:12px;">
<div style="display:flex;flex-direction:column;align-items:center;">
<div style="width:32px;height:32px;border-radius:50%;background:#2563eb;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">1</div>
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
</div>
<div style="padding-bottom:16px;">
<div style="font-size:13px;font-weight:700;color:#0f172a;">Purchase Request</div>
<div style="font-size:11px;color:#64748b;">Operations team fills in items needed</div>
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#eff6ff;color:#2563eb;">EXISTING · purchase_requests</div>
</div>
</div>
<!-- Stage 2 -->
<div style="display:flex;align-items:stretch;gap:12px;">
<div style="display:flex;flex-direction:column;align-items:center;">
<div style="width:32px;height:32px;border-radius:50%;background:#7c3aed;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">2</div>
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
</div>
<div style="padding-bottom:16px;">
<div style="font-size:13px;font-weight:700;color:#0f172a;">GM Digital Signature</div>
<div style="font-size:11px;color:#64748b;">GM draws signature on canvas, saved as image</div>
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#f5f3ff;color:#7c3aed;">NEW · purchase_signatures</div>
</div>
</div>
<!-- Stage 3+4 -->
<div style="display:flex;align-items:stretch;gap:12px;">
<div style="display:flex;flex-direction:column;align-items:center;">
<div style="width:32px;height:32px;border-radius:50%;background:#0ea5e9;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">3</div>
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
</div>
<div style="padding-bottom:16px;">
<div style="font-size:13px;font-weight:700;color:#0f172a;">Select Suppliers → Send RFQ Links</div>
<div style="font-size:11px;color:#64748b;">Pick suppliers from list, system sends unique links via WhatsApp/Email</div>
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#f0f9ff;color:#0ea5e9;">NEW · rfq_invitations</div>
</div>
</div>
<!-- Stage 4 -->
<div style="display:flex;align-items:stretch;gap:12px;">
<div style="display:flex;flex-direction:column;align-items:center;">
<div style="width:32px;height:32px;border-radius:50%;background:#f59e0b;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">4</div>
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
</div>
<div style="padding-bottom:16px;">
<div style="font-size:13px;font-weight:700;color:#0f172a;">Suppliers Submit Quotes</div>
<div style="font-size:11px;color:#64748b;">Each supplier opens their private link, fills price + terms, submits</div>
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#fffbeb;color:#b45309;">NEW · supplier_quotes + quote_items</div>
</div>
</div>
<!-- Stage 5 -->
<div style="display:flex;align-items:stretch;gap:12px;">
<div style="display:flex;flex-direction:column;align-items:center;">
<div style="width:32px;height:32px;border-radius:50%;background:#f59e0b;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">5</div>
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
</div>
<div style="padding-bottom:16px;">
<div style="font-size:13px;font-weight:700;color:#0f172a;">Quote Comparison & Award</div>
<div style="font-size:11px;color:#64748b;">Side-by-side table, team picks winner, records decision reason</div>
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#fffbeb;color:#b45309;">NEW · supplier_quotes (awarded flag)</div>
</div>
</div>
<!-- Stage 6 -->
<div style="display:flex;align-items:stretch;gap:12px;">
<div style="display:flex;flex-direction:column;align-items:center;">
<div style="width:32px;height:32px;border-radius:50%;background:#16a34a;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">6</div>
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
</div>
<div style="padding-bottom:16px;">
<div style="font-size:13px;font-weight:700;color:#0f172a;">Issue LPO</div>
<div style="font-size:11px;color:#64748b;">Create & send Local Purchase Order to winning supplier</div>
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#f0fdf4;color:#16a34a;">EXISTING · purchase_orders (LPO)</div>
</div>
</div>
<!-- Stage 7 -->
<div style="display:flex;align-items:stretch;gap:12px;">
<div style="display:flex;flex-direction:column;align-items:center;">
<div style="width:32px;height:32px;border-radius:50%;background:#16a34a;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">7</div>
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
</div>
<div style="padding-bottom:16px;">
<div style="font-size:13px;font-weight:700;color:#0f172a;">Receive Materials at Site</div>
<div style="font-size:11px;color:#64748b;">GRN created — each item flagged Inventory or Consumable</div>
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#f0fdf4;color:#16a34a;">EXISTING · goods_receipt_notes + grn_items</div>
</div>
</div>
<!-- Stage 8 -->
<div style="display:flex;align-items:stretch;gap:12px;">
<div style="display:flex;flex-direction:column;align-items:center;">
<div style="width:32px;height:32px;border-radius:50%;background:#0f172a;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">8</div>
</div>
<div style="padding-bottom:4px;">
<div style="font-size:13px;font-weight:700;color:#0f172a;">Issue Payment / Cheque</div>
<div style="font-size:11px;color:#64748b;">Record cheque number, bank, amount, payment date</div>
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#f1f5f9;color:#475569;">EXISTING · supplier_payments</div>
</div>
</div>
</div>
</div>
<!-- Right: New tables + public portal -->
<div>
<div class="label" style="margin-bottom:12px;">3 New Database Tables</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:14px;margin-bottom:12px;">
<div style="font-size:12px;font-weight:700;color:#7c3aed;margin-bottom:8px;">purchase_signatures</div>
<div style="font-size:11px;color:#475569;font-family:monospace;line-height:1.8;">
id<br>purchase_request_id → FK<br>signed_by → FK users<br>signature_image → base64 png<br>signed_at → timestamp<br>ip_address
</div>
</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:14px;margin-bottom:12px;">
<div style="font-size:12px;font-weight:700;color:#0ea5e9;margin-bottom:8px;">rfq_invitations</div>
<div style="font-size:11px;color:#475569;font-family:monospace;line-height:1.8;">
id<br>purchase_request_id → FK<br>supplier_id → FK<br>token → unique 64-char hex<br>channel → email|whatsapp|both<br>sent_at, opened_at, expires_at<br>status → pending|opened|submitted|declined
</div>
</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:14px;margin-bottom:16px;">
<div style="font-size:12px;font-weight:700;color:#f59e0b;margin-bottom:8px;">supplier_quotes + supplier_quote_items</div>
<div style="font-size:11px;color:#475569;font-family:monospace;line-height:1.8;">
id<br>rfq_invitation_id → FK<br>submitted_at<br>lead_time_days<br>payment_terms<br>notes<br>total_amount<br>is_awarded → boolean<br>award_reason<br>── items ──<br>description, unit, qty, unit_price, total
</div>
</div>
<div class="label" style="margin-bottom:8px;">Public Portal (no auth)</div>
<div style="background:#fefce8;border:1px solid #fde68a;border-radius:12px;padding:14px;">
<div style="font-size:12px;font-weight:700;color:#92400e;margin-bottom:6px;">🔗 /rfq/{token}</div>
<div style="font-size:11px;color:#78350f;line-height:1.7;">
• Outside auth middleware — no login needed<br>
• Token validated on every request<br>
• Expires 7 days after sending<br>
• Can only be submitted once<br>
• Shows your company name + item list<br>
• Supplier fills price/terms, submits<br>
• Confirmation screen shown after submit
</div>
</div>
<div style="margin-top:12px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:12px;padding:14px;">
<div style="font-size:12px;font-weight:700;color:#15803d;margin-bottom:6px;">Existing models — minor additions only</div>
<div style="font-size:11px;color:#166534;line-height:1.7;">
purchase_requests → add <strong>stage</strong> column<br>
grn_items → add <strong>type</strong> (inventory|consumable)<br>
purchase_orders → relabel as LPO in UI only
</div>
</div>
</div>
</div>
<p class="subtitle" style="margin-top:16px;">Does this architecture look right? Reply in the terminal.</p>

View File

@ -0,0 +1,127 @@
<h2>How should the purchase pipeline tracker look?</h2>
<p class="subtitle">Each purchase moves through stages like a delivery. Which style fits your team best?</p>
<div class="cards">
<!-- Option A: Horizontal progress bar (package tracking style) -->
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image" style="padding:18px;background:#f8fafc;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">PO-2024-001 · Steel Pipes · BD 4,200</div>
<div style="display:flex;align-items:center;gap:0;position:relative;margin-bottom:8px;">
<!-- Steps -->
<div style="display:flex;align-items:center;width:100%;">
<div style="text-align:center;flex:1;">
<div style="width:28px;height:28px;border-radius:50%;background:#2563eb;color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;"></div>
<div style="font-size:9px;color:#2563eb;font-weight:600;">Request</div>
</div>
<div style="flex:1;height:2px;background:#2563eb;margin-bottom:16px;"></div>
<div style="text-align:center;flex:1;">
<div style="width:28px;height:28px;border-radius:50%;background:#2563eb;color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;"></div>
<div style="font-size:9px;color:#2563eb;font-weight:600;">GM Sign</div>
</div>
<div style="flex:1;height:2px;background:#2563eb;margin-bottom:16px;"></div>
<div style="text-align:center;flex:1;">
<div style="width:28px;height:28px;border-radius:50%;background:#2563eb;color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;"></div>
<div style="font-size:9px;color:#2563eb;font-weight:600;">Suppliers</div>
</div>
<div style="flex:1;height:2px;background:#f59e0b;margin-bottom:16px;"></div>
<div style="text-align:center;flex:1;">
<div style="width:28px;height:28px;border-radius:50%;background:#f59e0b;color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;">4</div>
<div style="font-size:9px;color:#f59e0b;font-weight:700;">Quotes ◄</div>
</div>
<div style="flex:1;height:2px;background:#e2e8f0;margin-bottom:16px;"></div>
<div style="text-align:center;flex:1;">
<div style="width:28px;height:28px;border-radius:50%;background:#e2e8f0;color:#94a3b8;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;">5</div>
<div style="font-size:9px;color:#94a3b8;">LPO</div>
</div>
<div style="flex:1;height:2px;background:#e2e8f0;margin-bottom:16px;"></div>
<div style="text-align:center;flex:1;">
<div style="width:28px;height:28px;border-radius:50%;background:#e2e8f0;color:#94a3b8;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;">6</div>
<div style="font-size:9px;color:#94a3b8;">Receive</div>
</div>
<div style="flex:1;height:2px;background:#e2e8f0;margin-bottom:16px;"></div>
<div style="text-align:center;flex:1;">
<div style="width:28px;height:28px;border-radius:50%;background:#e2e8f0;color:#94a3b8;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;">7</div>
<div style="font-size:9px;color:#94a3b8;">Payment</div>
</div>
</div>
</div>
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:6px 10px;font-size:11px;color:#92400e;font-weight:600;">
⏳ Waiting for supplier quotes — 2 of 3 received
</div>
</div>
<div class="card-body">
<h3>A — Horizontal Pipeline</h3>
<p>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.</p>
</div>
</div>
<!-- Option B: Vertical timeline (delivery tracking style) -->
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image" style="padding:18px;background:#f8fafc;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">PO-2024-001 · Steel Pipes</div>
<div style="position:relative;padding-left:28px;">
<!-- vertical line -->
<div style="position:absolute;left:10px;top:4px;bottom:4px;width:2px;background:linear-gradient(to bottom,#2563eb 45%,#e2e8f0 45%);border-radius:2px;"></div>
<div style="margin-bottom:10px;position:relative;">
<div style="position:absolute;left:-22px;width:14px;height:14px;border-radius:50%;background:#2563eb;border:2px solid #fff;box-shadow:0 0 0 2px #2563eb;"></div>
<div style="font-size:11px;font-weight:700;color:#2563eb;">Request Created</div>
<div style="font-size:10px;color:#94a3b8;">May 12 · Operations Team</div>
</div>
<div style="margin-bottom:10px;position:relative;">
<div style="position:absolute;left:-22px;width:14px;height:14px;border-radius:50%;background:#2563eb;border:2px solid #fff;box-shadow:0 0 0 2px #2563eb;"></div>
<div style="font-size:11px;font-weight:700;color:#2563eb;">GM Approved</div>
<div style="font-size:10px;color:#94a3b8;">May 13 · Ahmed Al-Rashid</div>
</div>
<div style="margin-bottom:10px;position:relative;">
<div style="position:absolute;left:-22px;width:14px;height:14px;border-radius:50%;background:#f59e0b;border:2px solid #fff;box-shadow:0 0 0 2px #f59e0b;"></div>
<div style="font-size:11px;font-weight:700;color:#f59e0b;">Awaiting Quotes (2/3)</div>
<div style="font-size:10px;color:#94a3b8;">Sent to 3 suppliers · May 14</div>
</div>
<div style="margin-bottom:10px;position:relative;opacity:.45;">
<div style="position:absolute;left:-22px;width:14px;height:14px;border-radius:50%;background:#e2e8f0;border:2px solid #fff;"></div>
<div style="font-size:11px;font-weight:600;color:#64748b;">Quote Comparison</div>
</div>
<div style="position:relative;opacity:.45;">
<div style="position:absolute;left:-22px;width:14px;height:14px;border-radius:50%;background:#e2e8f0;border:2px solid #fff;"></div>
<div style="font-size:11px;font-weight:600;color:#64748b;">Issue LPO → Receive → Pay</div>
</div>
</div>
</div>
<div class="card-body">
<h3>B — Vertical Timeline</h3>
<p>Like a courier tracking page — each step logged with date and actor. Best for the detail view of a single purchase. Shows history clearly.</p>
</div>
</div>
<!-- Option C: Both combined -->
<div class="card" data-choice="c" onclick="toggleSelect(this)">
<div class="card-image" style="padding:18px;background:#f8fafc;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">PO-2024-001 · Steel Pipes · BD 4,200</div>
<!-- Compact horizontal on list -->
<div style="display:flex;gap:3px;margin-bottom:10px;">
<div style="flex:1;height:6px;border-radius:3px;background:#2563eb;"></div>
<div style="flex:1;height:6px;border-radius:3px;background:#2563eb;"></div>
<div style="flex:1;height:6px;border-radius:3px;background:#2563eb;"></div>
<div style="flex:1;height:6px;border-radius:3px;background:#f59e0b;animation:pulse 1.5s infinite;"></div>
<div style="flex:1;height:6px;border-radius:3px;background:#e2e8f0;"></div>
<div style="flex:1;height:6px;border-radius:3px;background:#e2e8f0;"></div>
<div style="flex:1;height:6px;border-radius:3px;background:#e2e8f0;"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:9px;color:#94a3b8;margin-bottom:10px;">
<span>Request</span><span>GM</span><span>RFQ</span><span style="color:#f59e0b;font-weight:700;">Quotes▲</span><span>LPO</span><span>Receive</span><span>Pay</span>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:8px 12px;font-size:11px;display:flex;align-items:center;justify-content:space-between;">
<span style="color:#f59e0b;font-weight:600;">⏳ Quotes: 2 of 3 received</span>
<span style="color:#2563eb;font-weight:600;cursor:pointer;">Open →</span>
</div>
</div>
<div class="card-body">
<h3>C — Combined (Recommended)</h3>
<p>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.</p>
</div>
</div>
</div>
<p class="subtitle" style="margin-top:16px;">Click a card to select, then reply in the terminal.</p>

View File

@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal…</p>
</div>

View File

@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Writing the spec and implementation plan…</p>
</div>

View File

@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1779110714918}

View File

@ -0,0 +1 @@
1542

362
CLAUDE.md Normal file
View File

@ -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 `<style>` block
- Show a live count ("12 of 47 suppliers") next to the search input
- Show a "No results" message when `visible === 0 && q !== ''`
- Never add `?search=` query params or submit a form for search
### 7. Never use `alert()`, `confirm()`, or `prompt()` — use toasts and modals
These native browser dialogs are ugly, block the thread, and cannot be styled. They are **banned** across the entire project.
- Use `showToast('msg', 'type')` for notifications instead of `alert()`
- Use a custom modal (like the delete confirmation modal) instead of `confirm()`
- Use a styled modal with an `<input>` instead of `prompt()`
### 8. Route parameter names must never be `{request}`
Laravel injects `Illuminate\Http\Request` by type-hint into `$request`. If a route parameter is also named `{request}`, implicit model binding silently fails — the model parameter receives null/empty, causing NOT NULL violations or UrlGenerationException when generating URLs from that model.
**Rule:** Always name route parameters after the model, matching the controller variable name exactly:
```php
// WRONG — {request} clashes with Request $request injection
Route::post('requests/{request}/sign', [PurchaseSignatureController::class, 'store']);
// store(Request $request, PurchaseRequest $purchaseRequest) — $purchaseRequest gets null
// CORRECT
Route::post('requests/{purchaseRequest}/sign', [PurchaseSignatureController::class, 'store']);
// store(Request $request, PurchaseRequest $purchaseRequest) — binding works
```
**Route::resource also generates `{request}` for a resource named `requests`.** Always override it:
```php
// WRONG
Route::resource('requests', PurchaseRequestController::class);
// Generates {request} — clashes with $request injection in every method
// CORRECT
Route::resource('requests', PurchaseRequestController::class)->parameters(['requests' => 'purchaseRequest']);
// Generates {purchaseRequest} — matches $purchaseRequest in controller methods
```
Use `{purchaseRequest}`, `{purchaseOrder}`, `{supplier}`, etc. — never `{request}`.
### 9. Status notifications — always use toasts, never inline banners
All success/error/info/warning messages MUST be displayed as toasts, not as `<div>` banners inside the page content. The global toast system is wired into `layouts/app.blade.php` and fires automatically from Laravel session flash keys (`success`, `error`, `info`, `warning`). In controllers, use:
```php
return redirect()->route('...')->with('success', 'Done.');
return redirect()->route('...')->with('error', 'Something failed.');
```
To trigger a toast from JavaScript (e.g. after an in-page action), call:
```javascript
showToast('Message text', 'success'); // types: success | error | info | warn
```
**Never** add inline `@if(session('success'))` banner divs to individual views — the layout handles all of them. The toast appears bottom-right, auto-dismisses after 4 s, has a shrinking progress bar, and can be clicked or ×-closed early.

59
README.md Normal file
View File

@ -0,0 +1,59 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

View File

@ -0,0 +1,101 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class GenerateItemTemplate extends Command
{
protected $signature = 'items:template {--output= : Save path}';
protected $description = 'Generate a clean Excel template for importing inventory items';
private array $columns = [
['item_name *', 'SUPERBOND F5 White 25kg', 36],
['unit_of_measure', 'BAG', 16],
['category', 'finished_good', 20],
['cost_price', '0.00', 14],
['minimum_stock_level', '0', 20],
['description', '', 36],
['is_active', 'yes', 12],
];
public function handle(): int
{
$outputPath = $this->option('output') ?? storage_path('app/items_template.xlsx');
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Items');
// Headers
foreach ($this->columns as $i => [$label]) {
$sheet->getCell(Coordinate::stringFromColumnIndex($i + 1) . '1')->setValue($label);
}
$lastCol = Coordinate::stringFromColumnIndex(count($this->columns));
$sheet->getStyle("A1:{$lastCol}1")->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF'], 'size' => 11],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '0f172a']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'FFFFFF']]],
]);
$sheet->getRowDimension(1)->setRowHeight(22);
// Example rows
$examples = [
['SUPERBOND F5 White 25kg', 'BAG', 'finished_good', '0.00', '0', '', 'yes'],
['SUPERBOND F5 Grey 25kg', 'BAG', 'finished_good', '0.00', '0', '', 'yes'],
['Pentaproof 20 P', 'KG', 'raw_material', '1.09', '0', '', 'yes'],
['JOLES YELLOW OCHRE', 'KG', 'raw_material', '1.32', '0', '', 'yes'],
['Silica Sand', 'KG', 'raw_material', '0.01', '0', '', 'yes'],
];
foreach ($examples as $rowIdx => $values) {
$excelRow = $rowIdx + 2;
foreach ($values as $colIdx => $value) {
$sheet->getCell(Coordinate::stringFromColumnIndex($colIdx + 1) . $excelRow)->setValue($value);
}
}
$lastDataRow = count($examples) + 1;
$sheet->getStyle("A2:{$lastCol}{$lastDataRow}")->applyFromArray([
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F8FAFC']],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'CBD5E1']]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
]);
// Notes
$notesRow = $lastDataRow + 2;
$sheet->getCell("A{$notesRow}")->setValue(
'* Required. category must be: finished_good, raw_material, or wip. Items with "Not in Use" in the name are imported as inactive. Delete example rows before importing.'
);
$sheet->mergeCells("A{$notesRow}:{$lastCol}{$notesRow}");
$sheet->getStyle("A{$notesRow}")->applyFromArray([
'font' => ['italic' => true, 'color' => ['rgb' => '6B7280'], 'size' => 9],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'FEF9C3']],
]);
// Column widths
foreach ($this->columns as $i => [, , $width]) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($i + 1))->setWidth($width);
}
$dir = dirname($outputPath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
(new Xlsx($spreadsheet))->save($outputPath);
$this->info("Template saved to: {$outputPath}");
$this->line("Usage: php artisan items:import \"<path-to-file.xlsx>\"");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class GenerateSupplierTemplate extends Command
{
protected $signature = 'suppliers:template
{--output= : Save path (default: storage/app/suppliers_template.xlsx)}';
protected $description = 'Generate a clean Excel template for importing suppliers';
/** [header label, example value, width] */
private array $columns = [
['Supplier ID', 'SUP-1006', 16],
['Company Name *', 'SAFETY CHEMICAL TRADING W.L.L', 38],
['Category', 'Chemical', 22],
['Contact Person', 'Ali Hassan', 24],
['Primary Email', 'ali@safetychem.com', 30],
['Secondary Email', '', 26],
['Phone 1', '+97333188311', 18],
['Phone 2', '', 18],
['WhatsApp Number', '+97334214947', 18],
['Address', 'Manama, Bahrain', 32],
['Website', 'https://example.com', 28],
['Credit (Y/N)', 'Y', 14],
['Credit Days', '30', 14],
['Tax Number', 'TRN100000000006', 22],
['Is Active', 'Yes', 12],
['Remarks / Key Details','', 38],
];
public function handle(): int
{
$outputPath = $this->option('output')
?? storage_path('app/suppliers_template.xlsx');
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Suppliers');
$lastCol = Coordinate::stringFromColumnIndex(count($this->columns));
// ── Header row ────────────────────────────────────────────────────────
foreach ($this->columns as $i => [$label]) {
$sheet->getCell(Coordinate::stringFromColumnIndex($i + 1) . '1')->setValue($label);
}
$sheet->getStyle("A1:{$lastCol}1")->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF'], 'size' => 11],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '1D4ED8']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'FFFFFF']]],
]);
$sheet->getRowDimension(1)->setRowHeight(22);
// ── Example rows ──────────────────────────────────────────────────────
$examples = [
['SUP-1006', 'SAFETY CHEMICAL TRADING W.L.L', 'Chemical', 'Ali Hassan', 'ali@safetychem.com', '', '+973 3318 8311', '', '+973 3421 4947', 'Manama, Bahrain', '', 'Y', '30', 'TRN100000000006', 'Yes', ''],
['SUP-1007', 'The prosperity trading & Cont SPC', 'Chemical', 'Sara Al-Ali', 'sara@prosperity.bh', '', '+973 17730001', '', '', 'Riffa, Bahrain', '', '', '', 'TRN100000000007', 'Yes', ''],
['SUP-1008', 'Al Eradah chemicals', 'Chemical', 'Mohammed Naser', 'info@aleradah.com', '', '+973 1741 3720', '', '', 'Hamad Town, Bahrain','', '', '', '', 'Yes', ''],
];
foreach ($examples as $rowIdx => $values) {
$excelRow = $rowIdx + 2;
foreach ($values as $colIdx => $value) {
$sheet->getCell(Coordinate::stringFromColumnIndex($colIdx + 1) . $excelRow)->setValue($value);
}
}
$lastDataRow = count($examples) + 1;
$sheet->getStyle("A2:{$lastCol}{$lastDataRow}")->applyFromArray([
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'EFF6FF']],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'BFDBFE']]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getStyle("B2:B{$lastDataRow}")->applyFromArray([
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'DBEAFE']],
]);
for ($r = 2; $r <= $lastDataRow; $r++) {
$sheet->getRowDimension($r)->setRowHeight(18);
}
// ── Notes row ─────────────────────────────────────────────────────────
$notesRow = $lastDataRow + 2;
$sheet->getCell("A{$notesRow}")->setValue(
'* Required. Delete example rows before importing. Duplicate Company Names are skipped. ' .
'Supplier ID is optional — leave blank and one will be auto-assigned. ' .
'Category, Secondary Email, Phone 2, WhatsApp, Website, Credit, Remarks are informational only.'
);
$sheet->mergeCells("A{$notesRow}:{$lastCol}{$notesRow}");
$sheet->getStyle("A{$notesRow}")->applyFromArray([
'font' => ['italic' => true, 'color' => ['rgb' => '6B7280'], 'size' => 9],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'FEF9C3']],
]);
$sheet->getRowDimension($notesRow)->setRowHeight(30);
$sheet->getStyle("A{$notesRow}")->getAlignment()->setWrapText(true);
// ── Column widths ─────────────────────────────────────────────────────
foreach ($this->columns as $i => [, , $width]) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($i + 1))->setWidth($width);
}
$dir = dirname($outputPath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
(new Xlsx($spreadsheet))->save($outputPath);
$this->info("Template saved to: {$outputPath}");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,220 @@
<?php
namespace App\Console\Commands;
use App\Models\Supplier;
use Illuminate\Console\Command;
use PhpOffice\PhpSpreadsheet\IOFactory;
class ImportSuppliers extends Command
{
protected $signature = 'suppliers:import
{file : Full path to the Excel file (.xlsx or .xls)}
{--dry-run : Preview what would be imported without saving}';
protected $description = 'Import suppliers from an Excel file. Supports both the MRF comparison format and the clean suppliers template. Duplicate names are skipped automatically.';
public function handle(): int
{
$filePath = $this->argument('file');
$dryRun = $this->option('dry-run');
if (!file_exists($filePath)) {
$this->error("File not found: {$filePath}");
$this->line("Tip: Use an absolute path, e.g. php artisan suppliers:import \"C:\\path\\to\\file.xlsx\"");
return Command::FAILURE;
}
$this->info("Reading: " . basename($filePath));
try {
$spreadsheet = IOFactory::load($filePath);
} catch (\Exception $e) {
$this->error("Could not open file: " . $e->getMessage());
return Command::FAILURE;
}
$format = $this->detectFormat($spreadsheet);
$this->line("Detected format: <comment>{$format}</comment>");
$suppliers = $format === 'mrf'
? $this->extractFromMrf($spreadsheet)
: $this->extractFromTemplate($spreadsheet);
if (empty($suppliers)) {
$this->warn("No suppliers found in file.");
return Command::FAILURE;
}
$this->newLine();
$this->line(str_pad('', 60, '-'));
$this->line(sprintf(" %-30s %-10s %s", 'Name', 'Status', 'Notes'));
$this->line(str_pad('', 60, '-'));
$imported = 0;
$skipped = 0;
foreach ($suppliers as $data) {
$name = trim($data['name'] ?? '');
if (empty($name)) {
continue;
}
$exists = Supplier::whereRaw('LOWER(name) = ?', [strtolower($name)])->exists();
if ($exists) {
$this->line(sprintf(" %-30s <fg=yellow>SKIP</> already in database", substr($name, 0, 30)));
$skipped++;
continue;
}
if (!$dryRun) {
Supplier::create([
'name' => $name,
'contact_person' => $data['contact_person'] ?? null ?: null,
'email' => $data['email'] ?? null ?: null,
'phone' => $data['phone'] ?? null ?: null,
'address' => $data['address'] ?? null ?: null,
'tax_number' => $data['tax_number'] ?? null ?: null,
'is_active' => $this->parseBoolean($data['is_active'] ?? 'yes'),
]);
}
$this->line(sprintf(" %-30s <fg=green>%s</> %s",
substr($name, 0, 30),
$dryRun ? 'PREVIEW' : 'IMPORTED',
isset($data['email']) && $data['email'] ? $data['email'] : ''
));
$imported++;
}
$this->line(str_pad('', 60, '-'));
$this->newLine();
if ($dryRun) {
$this->info("DRY RUN complete — nothing was saved.");
$this->line("Would import: {$imported} | Would skip: {$skipped}");
} else {
$this->info("Import complete!");
$this->line("Imported: <fg=green>{$imported}</> | Skipped (duplicates): <fg=yellow>{$skipped}</>");
}
return Command::SUCCESS;
}
/**
* Detect whether the file is an MRF comparison sheet or a clean supplier template.
* MRF sheets have "S.No" in cell A4.
*/
private function detectFormat(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): string
{
$sheet = $spreadsheet->getActiveSheet();
$a4 = strtolower(trim((string) $sheet->getCell('A4')->getValue()));
return $a4 === 's.no' ? 'mrf' : 'template';
}
/**
* Extract supplier names from a Material Purchase Request comparison sheet.
* Suppliers appear as column headers in row 4, starting from column G.
*/
private function extractFromMrf(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
{
$sheet = $spreadsheet->getActiveSheet();
$headerRow = 4;
$startCol = 7; // Column G
$stopWords = ['comments if any', 'lowest price', 'avg price', 'recommendation'];
$suppliers = [];
for ($col = $startCol; $col <= 50; $col++) {
$cellCoord = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($col) . $headerRow;
$value = trim((string) $sheet->getCell($cellCoord)->getValue());
if (empty($value)) {
break;
}
if (in_array(strtolower($value), $stopWords)) {
break;
}
$suppliers[] = ['name' => $value];
}
return $suppliers;
}
/**
* Extract supplier rows from the clean import template.
* Row 1 must be headers matching: name, contact_person, email, phone, address, tax_number, is_active
*/
private function extractFromTemplate(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
{
$sheet = $spreadsheet->getActiveSheet();
$rows = $sheet->toArray(null, true, true, false);
$suppliers = [];
if (empty($rows)) {
return [];
}
// Normalise header row: strip asterisks/underscores, lowercase
$headers = array_map(
fn($h) => strtolower(trim(str_replace(['*', '_'], [' ', ' '], (string) $h))),
$rows[0]
);
// Aliases: normalised header text → internal field name (supports both old and new formats)
$aliases = [
'name' => 'name',
'company name' => 'name',
'contact person' => 'contact_person',
'email' => 'email',
'primary email' => 'email',
'phone' => 'phone',
'phone 1' => 'phone',
'address' => 'address',
'tax number' => 'tax_number',
'is active' => 'is_active',
];
$map = [];
foreach ($headers as $idx => $header) {
if (isset($aliases[$header]) && !isset($map[$aliases[$header]])) {
$map[$aliases[$header]] = $idx;
}
}
if (!isset($map['name'])) {
$this->error('Template is missing a recognised name column ("name", "Company Name") in row 1.');
return [];
}
foreach (array_slice($rows, 1) as $row) {
$name = trim((string) ($row[$map['name']] ?? ''));
// Skip empty rows and the notes/instruction row
if (empty($name) || str_starts_with($name, '*')) {
continue;
}
$entry = ['name' => $name];
foreach (['contact_person', 'email', 'phone', 'address', 'tax_number', 'is_active'] as $field) {
if (isset($map[$field])) {
$entry[$field] = trim((string) ($row[$map[$field]] ?? ''));
}
}
$suppliers[] = $entry;
}
return $suppliers;
}
private function parseBoolean(string $value): bool
{
return in_array(strtolower(trim($value)), ['yes', 'true', '1', 'active', 'y']);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: view('auth.verify-email');
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Models\Item;
use App\Models\ProductionOrder;
use App\Models\PurchaseOrder;
use App\Models\PurchaseRequest;
use App\Models\SalesInvoice;
use App\Models\StockLevel;
use Illuminate\Support\Facades\DB;
class DashboardController extends Controller
{
public function index()
{
$totalSales = SalesInvoice::sum('total_amount');
$inventoryValue = StockLevel::join('items', 'items.id', '=', 'stock_levels.item_id')
->select(DB::raw('SUM(stock_levels.quantity * items.cost_price) as value'))
->value('value') ?? 0;
$productionInProgress = ProductionOrder::where('status', 'in_progress')->count();
$purchasePending = PurchaseRequest::where('stage', '!=', 'complete')->count();
$outstandingReceivables = SalesInvoice::whereIn('status', ['unpaid', 'partial'])
->sum(DB::raw('total_amount - paid_amount'));
return view('dashboard', compact(
'totalSales',
'inventoryValue',
'productionInProgress',
'purchasePending',
'outstandingReceivables'
));
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Inventory;
use App\Http\Controllers\Controller;
use App\Models\Item;
use App\Services\ItemImportService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
class ItemController extends Controller
{
public function index()
{
$items = Item::paginate(20);
return view('inventory.items.index', compact('items'));
}
public function create()
{
return view('inventory.items.create');
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'type' => 'required|string|max:100',
'unit_of_measure' => 'required|string|max:50',
'cost_price' => 'nullable|numeric|min:0',
'selling_price' => 'nullable|numeric|min:0',
'minimum_stock_level' => 'nullable|numeric|min:0',
]);
$data = $request->all();
$data['item_code'] = 'ITEM-' . str_pad(Item::max('id') + 1, 5, '0', STR_PAD_LEFT);
Item::create($data);
return redirect()->route('inventory.items.index')->with('success', 'Item created successfully.');
}
public function show(Item $item)
{
return view('inventory.items.show', compact('item'));
}
public function edit(Item $item)
{
return view('inventory.items.edit', compact('item'));
}
public function update(Request $request, Item $item)
{
$request->validate([
'name' => 'required|string|max:255',
'type' => 'required|string|max:100',
'unit_of_measure' => 'required|string|max:50',
'cost_price' => 'nullable|numeric|min:0',
'selling_price' => 'nullable|numeric|min:0',
'minimum_stock_level' => 'nullable|numeric|min:0',
]);
$item->update($request->all());
return redirect()->route('inventory.items.index')->with('success', 'Item updated successfully.');
}
public function destroy(Item $item)
{
$item->delete();
return redirect()->route('inventory.items.index')->with('success', 'Item deleted successfully.');
}
public function import(Request $request)
{
$request->validate([
'file' => 'required|file|mimes:xlsx,xls|max:10240',
]);
try {
$result = app(ItemImportService::class)->import(
$request->file('file')->getPathname()
);
$msg = "Import complete: {$result['imported']} item(s) added";
if ($result['skipped'] > 0) {
$msg .= ", {$result['skipped']} skipped (already exist).";
} else {
$msg .= '.';
}
return redirect()->route('inventory.items.index')->with('success', $msg);
} catch (\Exception $e) {
return redirect()->route('inventory.items.index')
->with('error', 'Import failed: ' . $e->getMessage());
}
}
public function downloadTemplate()
{
$path = storage_path('app/items_template.xlsx');
if (!file_exists($path)) {
Artisan::call('items:template');
}
return response()->download($path, 'items_import_template.xlsx');
}
public function exportPdf()
{
$items = Item::orderBy('category')->orderBy('item_name')->get();
$pdf = Pdf::loadView('inventory.items.pdf', compact('items'))
->setPaper('a4', 'landscape');
return $pdf->download('items_' . now()->format('Y-m-d') . '.pdf');
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\Inventory;
use App\Http\Controllers\Controller;
use App\Models\Item;
use App\Models\StockLevel;
use App\Models\StockMovement;
use App\Models\Warehouse;
use Illuminate\Http\Request;
class StockMovementController extends Controller
{
public function index()
{
$movements = StockMovement::with(['item', 'warehouse'])->latest()->paginate(20);
return view('inventory.movements.index', compact('movements'));
}
public function create()
{
$items = Item::all();
$warehouses = Warehouse::all();
return view('inventory.movements.create', compact('items', 'warehouses'));
}
public function store(Request $request)
{
$request->validate([
'item_id' => 'required|exists:items,id',
'warehouse_id' => 'required|exists:warehouses,id',
'type' => 'required|in:in,out,adjustment',
'quantity' => 'required|numeric|min:0.01',
'notes' => 'nullable|string',
]);
$stockLevel = StockLevel::firstOrCreate(
['item_id' => $request->item_id, 'warehouse_id' => $request->warehouse_id],
['quantity' => 0]
);
if ($request->type === 'in' || $request->type === 'adjustment') {
$stockLevel->increment('quantity', $request->quantity);
} else {
// Decrement but do not go below zero
$decrement = min($request->quantity, $stockLevel->quantity);
$stockLevel->decrement('quantity', $decrement);
}
StockMovement::create([
'item_id' => $request->item_id,
'warehouse_id' => $request->warehouse_id,
'type' => $request->type,
'quantity' => $request->quantity,
'notes' => $request->notes,
'created_by' => auth()->id(),
]);
return redirect()->route('inventory.movements.index')->with('success', 'Stock movement recorded successfully.');
}
public function show(StockMovement $stockMovement)
{
$stockMovement->load(['item', 'warehouse']);
return view('inventory.movements.show', compact('stockMovement'));
}
public function edit(StockMovement $stockMovement)
{
return view('inventory.movements.edit', compact('stockMovement'));
}
public function update(Request $request, StockMovement $stockMovement)
{
$request->validate([
'notes' => 'nullable|string',
]);
$stockMovement->update($request->only('notes'));
return redirect()->route('inventory.movements.index')->with('success', 'Stock movement updated successfully.');
}
public function destroy(StockMovement $stockMovement)
{
$stockMovement->delete();
return redirect()->route('inventory.movements.index')->with('success', 'Stock movement deleted successfully.');
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Inventory;
use App\Http\Controllers\Controller;
use App\Models\Item;
use App\Models\StockLevel;
use App\Models\StockMovement;
use App\Models\Warehouse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class StockReportController extends Controller
{
public function summary()
{
$stockLevels = StockLevel::with(['item', 'warehouse'])->get();
return view('inventory.reports.summary', compact('stockLevels'));
}
public function movement(Request $request)
{
$query = StockMovement::with(['item', 'warehouse']);
if ($request->filled('from_date')) {
$query->whereDate('created_at', '>=', $request->from_date);
}
if ($request->filled('to_date')) {
$query->whereDate('created_at', '<=', $request->to_date);
}
if ($request->filled('item_id')) {
$query->where('item_id', $request->item_id);
}
$movements = $query->latest()->paginate(50)->withQueryString();
$items = Item::orderBy('item_name')->get();
return view('inventory.reports.movement', compact('movements', 'items'));
}
public function lowStock()
{
// Items whose total stock across all warehouses is below their minimum_stock_level
$items = Item::withSum('stockLevels', 'quantity')
->having(DB::raw('COALESCE(stock_levels_sum_quantity, 0)'), '<', DB::raw('minimum_stock_level'))
->get();
return view('inventory.reports.low-stock', compact('items'));
}
public function valuation()
{
$valuations = StockLevel::join('items', 'items.id', '=', 'stock_levels.item_id')
->join('warehouses', 'warehouses.id', '=', 'stock_levels.warehouse_id')
->select(
'items.id as item_id',
'items.item_name',
'items.item_code',
'warehouses.name as warehouse_name',
'stock_levels.quantity',
'items.cost_price',
DB::raw('stock_levels.quantity * items.cost_price as valuation')
)
->get();
$totalValuation = $valuations->sum('valuation');
return view('inventory.reports.valuation', compact('valuations', 'totalValuation'));
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Inventory;
use App\Http\Controllers\Controller;
use App\Models\Warehouse;
use Illuminate\Http\Request;
class WarehouseController extends Controller
{
public function index()
{
$warehouses = Warehouse::paginate(15);
return view('inventory.warehouses.index', compact('warehouses'));
}
public function create()
{
return view('inventory.warehouses.create');
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'location' => 'nullable|string|max:255',
]);
Warehouse::create($request->all());
return redirect()->route('inventory.warehouses.index')->with('success', 'Warehouse created successfully.');
}
public function show(Warehouse $warehouse)
{
return view('inventory.warehouses.show', compact('warehouse'));
}
public function edit(Warehouse $warehouse)
{
return view('inventory.warehouses.edit', compact('warehouse'));
}
public function update(Request $request, Warehouse $warehouse)
{
$request->validate([
'name' => 'required|string|max:255',
'location' => 'nullable|string|max:255',
]);
$warehouse->update($request->all());
return redirect()->route('inventory.warehouses.index')->with('success', 'Warehouse updated successfully.');
}
public function destroy(Warehouse $warehouse)
{
$warehouse->delete();
return redirect()->route('inventory.warehouses.index')->with('success', 'Warehouse deleted successfully.');
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Production;
use App\Http\Controllers\Controller;
use App\Models\BillOfMaterial;
use App\Models\Item;
use Illuminate\Http\Request;
class BillOfMaterialController extends Controller
{
public function index()
{
$boms = BillOfMaterial::with(['product', 'rawMaterial'])
->get()
->groupBy('product_id');
return view('production.bom.index', compact('boms'));
}
public function create()
{
$finishedGoods = Item::where('category', 'finished_good')->get();
$rawMaterials = Item::where('category', 'raw_material')->get();
return view('production.bom.create', compact('finishedGoods', 'rawMaterials'));
}
public function store(Request $request)
{
$request->validate([
'product_id' => 'required|exists:items,id',
'raw_material_id' => 'required|exists:items,id',
'quantity_required' => 'required|numeric|min:0.01',
'unit_of_measure' => 'required|string|max:50',
]);
BillOfMaterial::create($request->all());
return redirect()->route('production.bom.index')->with('success', 'BOM entry created successfully.');
}
public function show(BillOfMaterial $billOfMaterial)
{
$billOfMaterial->load(['product', 'rawMaterial']);
return view('production.bom.show', compact('billOfMaterial'));
}
public function edit(BillOfMaterial $billOfMaterial)
{
$finishedGoods = Item::where('category', 'finished_good')->get();
$rawMaterials = Item::where('category', 'raw_material')->get();
return view('production.bom.edit', compact('billOfMaterial', 'finishedGoods', 'rawMaterials'));
}
public function update(Request $request, BillOfMaterial $billOfMaterial)
{
$request->validate([
'product_id' => 'required|exists:items,id',
'raw_material_id' => 'required|exists:items,id',
'quantity_required' => 'required|numeric|min:0.01',
'unit_of_measure' => 'required|string|max:50',
]);
$billOfMaterial->update($request->all());
return redirect()->route('production.bom.index')->with('success', 'BOM entry updated successfully.');
}
public function destroy(BillOfMaterial $billOfMaterial)
{
$billOfMaterial->delete();
return redirect()->route('production.bom.index')->with('success', 'BOM entry deleted successfully.');
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Production;
use App\Http\Controllers\Controller;
use App\Models\Item;
use App\Models\MaterialIssue;
use App\Models\ProductionOrder;
use App\Models\StockLevel;
use App\Models\StockMovement;
use App\Models\Warehouse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MaterialIssueController extends Controller
{
public function index()
{
$issues = MaterialIssue::with(['productionOrder', 'item', 'warehouse'])->paginate(15);
return view('production.material-issues.index', compact('issues'));
}
public function create()
{
$productionOrders = ProductionOrder::whereIn('status', ['planned', 'in_progress'])->get();
$items = Item::where('category', 'raw_material')->get();
$warehouses = Warehouse::all();
return view('production.material-issues.create', compact('productionOrders', 'items', 'warehouses'));
}
public function store(Request $request)
{
$request->validate([
'production_order_id' => 'required|exists:production_orders,id',
'item_id' => 'required|exists:items,id',
'warehouse_id' => 'required|exists:warehouses,id',
'quantity' => 'required|numeric|min:0.01',
'issue_date' => 'required|date',
]);
DB::transaction(function () use ($request) {
$issueNumber = 'MI-' . str_pad(MaterialIssue::max('id') + 1, 5, '0', STR_PAD_LEFT);
$stockLevel = StockLevel::firstOrCreate(
['item_id' => $request->item_id, 'warehouse_id' => $request->warehouse_id],
['quantity' => 0]
);
// Decrement stock, floor at zero
$decrement = min($request->quantity, $stockLevel->quantity);
$stockLevel->decrement('quantity', $decrement);
StockMovement::create([
'item_id' => $request->item_id,
'warehouse_id' => $request->warehouse_id,
'type' => 'out',
'quantity' => $request->quantity,
'reference_type' => 'MaterialIssue',
'reference_id' => null, // will be updated after create
'created_by' => auth()->id(),
]);
$issue = MaterialIssue::create([
'issue_number' => $issueNumber,
'production_order_id' => $request->production_order_id,
'item_id' => $request->item_id,
'warehouse_id' => $request->warehouse_id,
'quantity' => $request->quantity,
'issue_date' => $request->issue_date,
'issued_by' => auth()->id(),
]);
// Backfill reference_id on the movement just created
StockMovement::where('item_id', $request->item_id)
->where('warehouse_id', $request->warehouse_id)
->where('type', 'out')
->where('reference_type', 'MaterialIssue')
->whereNull('reference_id')
->latest('id')
->first()
?->update(['reference_id' => $issue->id]);
});
return redirect()->back()->with('success', 'Material issued successfully.');
}
public function show(MaterialIssue $materialIssue)
{
$materialIssue->load(['productionOrder', 'item', 'warehouse']);
return view('production.material-issues.show', compact('materialIssue'));
}
public function edit(MaterialIssue $materialIssue)
{
return view('production.material-issues.edit', compact('materialIssue'));
}
public function update(Request $request, MaterialIssue $materialIssue)
{
$request->validate([
'issue_date' => 'required|date',
]);
$materialIssue->update($request->only('issue_date', 'notes'));
return redirect()->route('production.material-issues.index')->with('success', 'Material issue updated successfully.');
}
public function destroy(MaterialIssue $materialIssue)
{
$materialIssue->delete();
return redirect()->route('production.material-issues.index')->with('success', 'Material issue deleted successfully.');
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Production;
use App\Http\Controllers\Controller;
use App\Models\Item;
use App\Models\ProductionCost;
use App\Models\ProductionOrder;
use Illuminate\Http\Request;
class ProductionOrderController extends Controller
{
public function index()
{
$orders = ProductionOrder::with('product')->paginate(15);
return view('production.orders.index', compact('orders'));
}
public function create()
{
// Only finished_good type items can be produced
$items = Item::where('category', 'finished_good')->get();
return view('production.orders.create', compact('items'));
}
public function store(Request $request)
{
$request->validate([
'product_id' => 'required|exists:items,id',
'quantity_to_produce' => 'required|numeric|min:1',
'production_date' => 'required|date',
]);
$data = $request->all();
$data['order_number'] = 'PRO-' . str_pad(ProductionOrder::max('id') + 1, 5, '0', STR_PAD_LEFT);
$data['created_by'] = auth()->id();
$data['status'] = 'planned';
$order = ProductionOrder::create($data);
return redirect()->route('production.orders.show', $order)->with('success', 'Production order created successfully.');
}
public function show(ProductionOrder $productionOrder)
{
$productionOrder->load(['product', 'materialIssues.item', 'outputs.item', 'cost']);
return view('production.orders.show', compact('productionOrder'));
}
public function edit(ProductionOrder $productionOrder)
{
$items = Item::where('category', 'finished_good')->get();
return view('production.orders.edit', compact('productionOrder', 'items'));
}
public function update(Request $request, ProductionOrder $productionOrder)
{
$request->validate([
'production_date' => 'required|date',
]);
$productionOrder->update($request->only('production_date', 'notes'));
return redirect()->route('production.orders.show', $productionOrder)->with('success', 'Production order updated successfully.');
}
public function destroy(ProductionOrder $productionOrder)
{
$productionOrder->delete();
return redirect()->route('production.orders.index')->with('success', 'Production order deleted successfully.');
}
public function start(ProductionOrder $productionOrder)
{
$productionOrder->update(['status' => 'in_progress']);
return redirect()->back()->with('success', 'Production order started.');
}
public function complete(ProductionOrder $productionOrder)
{
$productionOrder->update([
'status' => 'completed',
'completion_date' => now(),
]);
return redirect()->back()->with('success', 'Production order marked as completed.');
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Production;
use App\Http\Controllers\Controller;
use App\Models\Item;
use App\Models\ProductionOrder;
use App\Models\ProductionOutput;
use App\Models\StockLevel;
use App\Models\StockMovement;
use App\Models\Warehouse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ProductionOutputController extends Controller
{
public function index()
{
$outputs = ProductionOutput::with(['productionOrder', 'item', 'warehouse'])->paginate(15);
return view('production.outputs.index', compact('outputs'));
}
public function create()
{
$productionOrders = ProductionOrder::whereIn('status', ['in_progress'])->with('product')->get();
$warehouses = Warehouse::all();
return view('production.outputs.create', compact('productionOrders', 'warehouses'));
}
public function store(Request $request)
{
$request->validate([
'production_order_id' => 'required|exists:production_orders,id',
'item_id' => 'required|exists:items,id',
'warehouse_id' => 'required|exists:warehouses,id',
'quantity' => 'required|numeric|min:0.01',
'output_date' => 'required|date',
]);
DB::transaction(function () use ($request) {
$stockLevel = StockLevel::firstOrCreate(
['item_id' => $request->item_id, 'warehouse_id' => $request->warehouse_id],
['quantity' => 0]
);
$stockLevel->increment('quantity', $request->quantity);
$output = ProductionOutput::create([
'production_order_id' => $request->production_order_id,
'item_id' => $request->item_id,
'warehouse_id' => $request->warehouse_id,
'quantity' => $request->quantity,
'output_date' => $request->output_date,
'recorded_by' => auth()->id(),
]);
StockMovement::create([
'item_id' => $request->item_id,
'warehouse_id' => $request->warehouse_id,
'type' => 'in',
'quantity' => $request->quantity,
'reference_type' => 'ProductionOutput',
'reference_id' => $output->id,
'created_by' => auth()->id(),
]);
// Accumulate quantity_produced on the production order
ProductionOrder::where('id', $request->production_order_id)
->increment('quantity_produced', $request->quantity);
});
return redirect()->back()->with('success', 'Production output recorded and stock updated.');
}
public function show(ProductionOutput $productionOutput)
{
$productionOutput->load(['productionOrder', 'item', 'warehouse']);
return view('production.outputs.show', compact('productionOutput'));
}
public function edit(ProductionOutput $productionOutput)
{
return view('production.outputs.edit', compact('productionOutput'));
}
public function update(Request $request, ProductionOutput $productionOutput)
{
$request->validate([
'output_date' => 'required|date',
]);
$productionOutput->update($request->only('output_date', 'notes'));
return redirect()->route('production.outputs.index')->with('success', 'Production output updated successfully.');
}
public function destroy(ProductionOutput $productionOutput)
{
$productionOutput->delete();
return redirect()->route('production.outputs.index')->with('success', 'Production output deleted successfully.');
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller;
use App\Models\GoodsReceiptNote;
use App\Models\GrnItem;
use App\Models\PurchaseOrder;
use App\Models\StockLevel;
use App\Models\StockMovement;
use App\Models\Warehouse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class GoodsReceiptNoteController extends Controller
{
public function index()
{
$grns = GoodsReceiptNote::with(['purchaseOrder.supplier', 'warehouse'])->paginate(15);
return view('purchase.grns.index', compact('grns'));
}
public function create()
{
$purchaseOrders = PurchaseOrder::whereIn('status', ['sent', 'partial'])->with('supplier')->get();
$warehouses = Warehouse::all();
return view('purchase.grns.create', compact('purchaseOrders', 'warehouses'));
}
public function store(Request $request)
{
$request->validate([
'purchase_order_id' => 'required|exists:purchase_orders,id',
'warehouse_id' => 'required|exists:warehouses,id',
'received_date' => 'required|date',
'items' => 'required|array|min:1',
'items.*.item_id' => 'required|exists:items,id',
'items.*.quantity' => 'required|numeric|min:1',
]);
$grnNumber = 'GRN-' . str_pad(GoodsReceiptNote::max('id') + 1, 5, '0', STR_PAD_LEFT);
$po = PurchaseOrder::findOrFail($request->purchase_order_id);
$grn = GoodsReceiptNote::create([
'grn_number' => $grnNumber,
'purchase_order_id' => $request->purchase_order_id,
'supplier_id' => $po->supplier_id,
'warehouse_id' => $request->warehouse_id,
'received_date' => $request->received_date,
'status' => 'draft',
'received_by' => auth()->id(),
]);
foreach ($request->items as $item) {
GrnItem::create([
'goods_receipt_note_id' => $grn->id,
'purchase_order_item_id' => $item['po_item_id'] ?? $po->items()->where('item_id', $item['item_id'])->first()?->id ?? 0,
'item_id' => $item['item_id'],
'quantity_received' => $item['quantity'],
'unit_cost' => $item['unit_cost'] ?? 0,
'type' => in_array($item['type'] ?? '', ['inventory', 'consumable']) ? $item['type'] : 'inventory',
]);
}
return redirect()->route('purchase.grns.show', $grn)->with('success', 'GRN created successfully.');
}
public function show(GoodsReceiptNote $grn)
{
$grn->load(['purchaseOrder.supplier', 'warehouse', 'items.item']);
return view('purchase.grns.show', compact('grn'));
}
public function edit(GoodsReceiptNote $grn)
{
$warehouses = Warehouse::all();
return view('purchase.grns.edit', compact('grn', 'warehouses'));
}
public function update(Request $request, GoodsReceiptNote $grn)
{
$request->validate([
'received_date' => 'required|date',
'warehouse_id' => 'required|exists:warehouses,id',
]);
$grn->update($request->only('received_date', 'warehouse_id'));
return redirect()->route('purchase.grns.show', $grn)->with('success', 'GRN updated successfully.');
}
public function destroy(GoodsReceiptNote $grn)
{
$grn->delete();
return redirect()->route('purchase.grns.index')->with('success', 'GRN deleted successfully.');
}
public function confirm(GoodsReceiptNote $grn)
{
if ($grn->status === 'confirmed') {
return redirect()->back()->with('error', 'GRN is already confirmed.');
}
DB::transaction(function () use ($grn) {
$grn->load('items');
foreach ($grn->items as $grnItem) {
$stockLevel = StockLevel::firstOrCreate(
['item_id' => $grnItem->item_id, 'warehouse_id' => $grn->warehouse_id],
['quantity' => 0]
);
$stockLevel->increment('quantity', $grnItem->quantity_received);
StockMovement::create([
'item_id' => $grnItem->item_id,
'warehouse_id' => $grn->warehouse_id,
'type' => 'in',
'quantity' => $grnItem->quantity_received,
'reference_type' => 'GoodsReceiptNote',
'reference_id' => $grn->id,
'created_by' => auth()->id(),
]);
$grn->purchaseOrder->items()
->where('item_id', $grnItem->item_id)
->increment('quantity_received', $grnItem->quantity_received);
}
$grn->update(['status' => 'confirmed']);
// Check if all PO items have been fully received
$po = $grn->purchaseOrder->fresh(['items']);
$allFullyReceived = $po->items->every(
fn($poItem) => $poItem->quantity_received >= $poItem->quantity
);
if ($allFullyReceived) {
$po->update(['status' => 'received']);
}
});
return redirect()->back()->with('success', 'GRN confirmed and stock updated.');
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller;
use App\Models\Item;
use App\Models\PurchaseOrder;
use App\Models\PurchaseOrderItem;
use App\Models\PurchaseRequest;
use App\Models\Supplier;
use Illuminate\Http\Request;
class PurchaseOrderController extends Controller
{
public function index()
{
$orders = PurchaseOrder::with('supplier')->paginate(15);
return view('purchase.orders.index', compact('orders'));
}
public function create()
{
$suppliers = Supplier::all();
$items = Item::all();
$purchaseRequests = PurchaseRequest::where('status', 'approved')->get();
return view('purchase.orders.create', compact('suppliers', 'items', 'purchaseRequests'));
}
public function store(Request $request)
{
$request->validate([
'supplier_id' => 'required|exists:suppliers,id',
'po_date' => 'required|date',
'items' => 'required|array|min:1',
'items.*.item_id' => 'required|exists:items,id',
'items.*.quantity' => 'required|numeric|min:1',
'items.*.rate' => 'required|numeric|min:0',
]);
$poNumber = 'PO-' . str_pad(PurchaseOrder::max('id') + 1, 5, '0', STR_PAD_LEFT);
$totalAmount = collect($request->items)->sum(fn($item) => $item['quantity'] * $item['rate']);
$order = PurchaseOrder::create([
'po_number' => $poNumber,
'supplier_id' => $request->supplier_id,
'po_date' => $request->po_date,
'total_amount' => $totalAmount,
'status' => 'draft',
'created_by' => auth()->id(),
]);
foreach ($request->items as $item) {
PurchaseOrderItem::create([
'purchase_order_id' => $order->id,
'item_id' => $item['item_id'],
'quantity' => $item['quantity'],
'rate' => $item['rate'],
'amount' => $item['quantity'] * $item['rate'],
]);
}
return redirect()->route('purchase.orders.show', $order)->with('success', 'Purchase order created successfully.');
}
public function show(PurchaseOrder $purchaseOrder)
{
$purchaseOrder->load(['supplier', 'items.item']);
return view('purchase.orders.show', compact('purchaseOrder'));
}
public function edit(PurchaseOrder $purchaseOrder)
{
$suppliers = Supplier::all();
$items = Item::all();
return view('purchase.orders.edit', compact('purchaseOrder', 'suppliers', 'items'));
}
public function update(Request $request, PurchaseOrder $purchaseOrder)
{
$request->validate([
'supplier_id' => 'required|exists:suppliers,id',
'po_date' => 'required|date',
]);
$purchaseOrder->update($request->only('supplier_id', 'po_date', 'status'));
return redirect()->route('purchase.orders.show', $purchaseOrder)->with('success', 'Purchase order updated successfully.');
}
public function destroy(PurchaseOrder $purchaseOrder)
{
$purchaseOrder->delete();
return redirect()->route('purchase.orders.index')->with('success', 'Purchase order deleted successfully.');
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller;
use App\Models\PurchaseRequest;
use App\Models\Supplier;
use App\Services\PurchaseStageService;
class PurchasePipelineController extends Controller
{
private function withRelations()
{
return PurchaseRequest::with([
'requestedBy',
'signature.signedBy',
'rfqInvitations.supplier',
'supplierQuotes',
'awardedQuote.supplier',
]);
}
public function index(PurchaseStageService $stages)
{
$active = $this->withRelations()->where('stage', '!=', 'complete')->latest()->get();
$completed = $this->withRelations()->where('stage', 'complete')->latest()->get();
return view('purchase.pipeline.index', compact('active', 'completed', 'stages'));
}
public function show(PurchaseRequest $purchaseRequest, PurchaseStageService $stages)
{
$purchaseRequest->load([
'requestedBy',
'items',
'signature.signedBy',
'rfqInvitations.supplier',
'supplierQuotes',
'awardedQuote.supplier',
]);
$suppliers = Supplier::where('is_active', true)->orderBy('name')->get();
return view('purchase.pipeline.show', [
'pr' => $purchaseRequest,
'stages' => $stages,
'suppliers' => $suppliers,
]);
}
}

View File

@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller;
use App\Models\PurchaseRequest;
use App\Models\PurchaseRequestItem;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class PurchaseRequestController extends Controller
{
public function index()
{
$requests = PurchaseRequest::with(['requestedBy', 'items'])->latest()->paginate(15);
return view('purchase.requests.index', compact('requests'));
}
public function create()
{
return view('purchase.requests.create');
}
public function store(Request $request)
{
$request->validate([
'date' => 'required|date',
'project_name' => 'required|string|max:255',
'department' => 'nullable|string|max:255',
'requested_by_name' => 'required|string|max:255',
'required_date_text' => 'nullable|string|max:100',
'location' => 'nullable|string|max:255',
'remarks' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string',
'items.*.unit' => 'nullable|string|max:50',
'items.*.quantity_required' => 'required|numeric|min:0.01',
'items.*.purpose_use' => 'nullable|string|max:255',
'items.*.required_date' => 'nullable|date',
]);
DB::transaction(function () use ($request) {
$year = now()->format('y');
$next = PurchaseRequest::max('id') + 1;
$mprNumber = 'MPR' . $year . '-' . str_pad($next, 4, '0', STR_PAD_LEFT);
$pr = PurchaseRequest::create([
'request_number' => $mprNumber,
'date' => $request->date,
'project_name' => $request->project_name,
'department' => $request->department,
'requested_by_name' => $request->requested_by_name,
'required_date_text' => $request->required_date_text,
'location' => $request->location,
'remarks' => $request->remarks,
'status' => 'pending',
'requested_by' => auth()->id(),
]);
foreach ($request->items as $item) {
if (empty(trim($item['description']))) {
continue;
}
PurchaseRequestItem::create([
'purchase_request_id' => $pr->id,
'description' => $item['description'],
'unit' => $item['unit'] ?? null,
'quantity_required' => $item['quantity_required'],
'purpose_use' => $item['purpose_use'] ?? null,
'required_date' => $item['required_date'] ?? null,
]);
}
});
return redirect()->route('purchase.requests.index')->with('success', 'Purchase request submitted successfully.');
}
public function show(PurchaseRequest $purchaseRequest)
{
$purchaseRequest->load(['items', 'requestedBy', 'approvedBy']);
return view('purchase.requests.show', compact('purchaseRequest'));
}
public function edit(PurchaseRequest $purchaseRequest)
{
$purchaseRequest->load('items');
return view('purchase.requests.edit', compact('purchaseRequest'));
}
public function update(Request $request, PurchaseRequest $purchaseRequest)
{
$request->validate([
'date' => 'required|date',
'project_name' => 'required|string|max:255',
'department' => 'nullable|string|max:255',
'requested_by_name' => 'required|string|max:255',
'required_date_text' => 'nullable|string|max:100',
'location' => 'nullable|string|max:255',
'remarks' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string',
'items.*.unit' => 'nullable|string|max:50',
'items.*.quantity_required' => 'required|numeric|min:0.01',
'items.*.purpose_use' => 'nullable|string|max:255',
'items.*.required_date' => 'nullable|date',
]);
DB::transaction(function () use ($request, $purchaseRequest) {
$purchaseRequest->update([
'date' => $request->date,
'project_name' => $request->project_name,
'department' => $request->department,
'requested_by_name' => $request->requested_by_name,
'required_date_text' => $request->required_date_text,
'location' => $request->location,
'remarks' => $request->remarks,
]);
$purchaseRequest->items()->delete();
foreach ($request->items as $item) {
if (empty(trim($item['description']))) {
continue;
}
PurchaseRequestItem::create([
'purchase_request_id' => $purchaseRequest->id,
'description' => $item['description'],
'unit' => $item['unit'] ?? null,
'quantity_required' => $item['quantity_required'],
'purpose_use' => $item['purpose_use'] ?? null,
'required_date' => $item['required_date'] ?? null,
]);
}
});
return redirect()->route('purchase.requests.show', $purchaseRequest)->with('success', 'Purchase request updated successfully.');
}
public function destroy(PurchaseRequest $purchaseRequest)
{
$purchaseRequest->delete();
return redirect()->route('purchase.requests.index')->with('success', 'Purchase request deleted.');
}
public function approve(PurchaseRequest $purchaseRequest)
{
$purchaseRequest->update([
'status' => 'approved',
'approved_by' => auth()->id(),
'approved_at' => now(),
]);
return redirect()->back()->with('success', 'Purchase request approved.');
}
public function reject(PurchaseRequest $purchaseRequest)
{
$purchaseRequest->update(['status' => 'rejected']);
return redirect()->back()->with('success', 'Purchase request rejected.');
}
public function print(PurchaseRequest $purchaseRequest)
{
$purchaseRequest->load(['items', 'requestedBy', 'approvedBy']);
return view('purchase.requests.print', compact('purchaseRequest'));
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller;
use App\Models\PurchaseRequest;
use App\Models\PurchaseSignature;
use App\Services\PurchaseStageService;
use Illuminate\Http\Request;
class PurchaseSignatureController extends Controller
{
public function show(PurchaseRequest $purchaseRequest)
{
$purchaseRequest->load('signature.signedBy');
return view('purchase.signature.show', ['request' => $purchaseRequest]);
}
public function store(Request $request, PurchaseRequest $purchaseRequest, PurchaseStageService $stages)
{
$validated = $request->validate([
'signature_image' => ['required', 'string'],
]);
if ($purchaseRequest->signature) {
return back()->with('error', 'This request has already been signed.');
}
PurchaseSignature::create([
'purchase_request_id' => $purchaseRequest->id,
'signed_by' => auth()->id(),
'signature_image' => $validated['signature_image'],
'signed_at' => now(),
'ip_address' => $request->ip(),
]);
$stages->advance($purchaseRequest);
return redirect()->route('purchase.pipeline.index')
->with('success', 'Signature saved. Request moved to RFQ stage.');
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller;
use App\Models\PurchaseRequest;
use App\Models\Supplier;
use App\Services\PurchaseStageService;
use App\Services\RfqInvitationService;
use Illuminate\Http\Request;
class RfqController extends Controller
{
public function show(PurchaseRequest $purchaseRequest)
{
$suppliers = Supplier::where('is_active', true)->orderBy('name')->get();
$invitations = $purchaseRequest->rfqInvitations()->with('supplier', 'quote')->get();
return view('purchase.rfq.show', ['request' => $purchaseRequest, 'suppliers' => $suppliers, 'invitations' => $invitations]);
}
public function selectSuppliers(Request $request, PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
{
$mode = $request->input('mode', 'global');
$alreadySelected = $purchaseRequest->rfqInvitations()->pluck('supplier_id')->toArray();
$added = 0;
if ($mode === 'by_item') {
// item_suppliers[item_id][] = supplier_id → invert to supplier_id → [item_ids]
$itemSuppliers = $request->input('item_suppliers', []);
$supplierItems = [];
foreach ($itemSuppliers as $itemId => $supplierIds) {
foreach ((array) $supplierIds as $supplierId) {
$supplierItems[$supplierId][] = (int) $itemId;
}
}
if (empty($supplierItems)) {
return redirect()->back()->with('error', 'Please assign at least one supplier to an item.');
}
foreach ($supplierItems as $supplierId => $itemIds) {
if (in_array($supplierId, $alreadySelected)) {
continue;
}
$channel = $request->input('channel_' . $supplierId, 'email');
$supplier = Supplier::findOrFail($supplierId);
$service->select($purchaseRequest, $supplier, $channel, $itemIds);
$added++;
}
} else {
$validated = $request->validate([
'supplier_ids' => ['required', 'array', 'min:1'],
'supplier_ids.*' => ['required', 'exists:suppliers,id'],
]);
foreach ($validated['supplier_ids'] as $supplierId) {
if (in_array($supplierId, $alreadySelected)) {
continue;
}
$channel = $request->input('channel_' . $supplierId, 'email');
$supplier = Supplier::findOrFail($supplierId);
$service->select($purchaseRequest, $supplier, $channel);
$added++;
}
}
$stages->setStage($purchaseRequest, 'rfq');
return redirect()->route('purchase.pipeline.show', $purchaseRequest)
->with('success', $added . ' supplier(s) added. Now send them the quote request links.');
}
public function sendAll(PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
{
$pending = $purchaseRequest->rfqInvitations()->where('status', 'pending')->with('supplier')->get();
if ($pending->isEmpty()) {
return redirect()->back()->with('error', 'No unsent invitations. Select suppliers first.');
}
foreach ($pending as $invitation) {
$service->sendInvitation($invitation);
}
$stages->setStage($purchaseRequest, 'quoting');
return redirect()->route('purchase.pipeline.show', $purchaseRequest)
->with('success', $pending->count() . ' supplier(s) notified. Waiting for quotes.');
}
public function store(Request $request, PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
{
$validated = $request->validate([
'supplier_ids' => ['required', 'array', 'min:1'],
'supplier_ids.*' => ['required', 'exists:suppliers,id'],
]);
$alreadyInvited = $purchaseRequest->rfqInvitations()->pluck('supplier_id')->toArray();
$sent = 0;
foreach ($validated['supplier_ids'] as $supplierId) {
if (in_array($supplierId, $alreadyInvited)) {
continue;
}
$channel = $request->input('channel_' . $supplierId, 'both');
$supplier = Supplier::findOrFail($supplierId);
$service->invite($purchaseRequest, $supplier, $channel);
$sent++;
}
$stages->setStage($purchaseRequest, 'quoting');
return redirect()->route('purchase.requests.rfq', $purchaseRequest)
->with('success', $sent . ' invitation(s) sent. Waiting for supplier quotes.');
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller;
use App\Models\RfqInvitation;
use App\Models\SupplierQuote;
use App\Models\SupplierQuoteItem;
use App\Services\PurchaseStageService;
use Illuminate\Http\Request;
class RfqPortalController extends Controller
{
private function resolve(string $token): RfqInvitation
{
return RfqInvitation::where('token', $token)
->with(['purchaseRequest.items', 'supplier'])
->firstOrFail();
}
public function show(string $token)
{
$invitation = $this->resolve($token);
if ($invitation->isSubmitted()) {
return view('rfq.submitted', compact('invitation'));
}
if ($invitation->isExpired()) {
return view('rfq.expired', compact('invitation'));
}
if ($invitation->status === 'sent') {
$invitation->update(['status' => 'opened', 'opened_at' => now()]);
}
$purchaseRequest = $invitation->purchaseRequest;
$items = $purchaseRequest->items;
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items'));
}
public function submit(Request $request, string $token)
{
$invitation = $this->resolve($token);
if ($invitation->isSubmitted() || $invitation->isExpired()) {
abort(403, 'This link is no longer valid.');
}
$validated = $request->validate([
'lead_time_days' => ['nullable', 'integer', 'min:0'],
'payment_terms' => ['nullable', 'string', 'max:200'],
'notes' => ['nullable', 'string', 'max:1000'],
'items' => ['required', 'array'],
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
]);
$purchaseItems = $invitation->purchaseRequest->items;
$quote = SupplierQuote::create([
'rfq_invitation_id' => $invitation->id,
'purchase_request_id' => $invitation->purchase_request_id,
'supplier_id' => $invitation->supplier_id,
'submitted_at' => now(),
'lead_time_days' => $validated['lead_time_days'],
'payment_terms' => $validated['payment_terms'],
'notes' => $validated['notes'],
'total_amount' => 0,
]);
$total = 0;
foreach ($purchaseItems as $i => $item) {
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0);
$qty = (float)$item->quantity;
$totalPrice = round($unitPrice * $qty, 3);
$total += $totalPrice;
SupplierQuoteItem::create([
'supplier_quote_id' => $quote->id,
'description' => $item->description,
'unit' => $item->unit ?? '',
'quantity' => $qty,
'unit_price' => $unitPrice,
'total_price' => $totalPrice,
]);
}
$quote->update(['total_amount' => round($total, 3)]);
$invitation->update(['status' => 'submitted']);
// If at least 1 quote is in, move to comparison stage
$pr = $invitation->purchaseRequest;
if ($pr->stage === 'quoting') {
app(PurchaseStageService::class)->setStage($pr, 'comparison');
}
return view('rfq.submitted', compact('invitation'));
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller;
use App\Models\Supplier;
use App\Services\SupplierImportService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
class SupplierController extends Controller
{
public function index()
{
$suppliers = Supplier::orderBy('name')->get();
$stats = [
'total' => $suppliers->count(),
'active' => $suppliers->where('is_active', true)->count(),
'inactive' => $suppliers->where('is_active', false)->count(),
'with_email' => $suppliers->filter(fn($s) => !empty($s->email))->count(),
'categories' => $suppliers->whereNotNull('category')->groupBy('category')->count(),
];
$categories = Supplier::whereNotNull('category')
->where('category', '!=', '')
->distinct()
->orderBy('category')
->pluck('category');
return view('purchase.suppliers.index', compact('suppliers', 'stats', 'categories'));
}
public function create()
{
return view('purchase.suppliers.create');
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'credit_days' => 'nullable|integer|min:0',
]);
Supplier::create(array_merge(
$request->only([
'supplier_code', 'name', 'category', 'contact_person',
'email', 'secondary_email', 'phone', 'phone2', 'whatsapp',
'address', 'website', 'tax_number', 'credit_terms', 'credit_days', 'remarks',
]),
['is_active' => (bool) $request->input('is_active', 1)]
));
return redirect()->route('purchase.suppliers.index')->with('success', 'Supplier created successfully.');
}
public function show(Supplier $supplier)
{
return view('purchase.suppliers.show', compact('supplier'));
}
public function edit(Supplier $supplier)
{
return view('purchase.suppliers.edit', compact('supplier'));
}
public function update(Request $request, Supplier $supplier)
{
$request->validate([
'name' => 'required|string|max:255',
'credit_days' => 'nullable|integer|min:0',
'is_active' => 'nullable',
]);
$supplier->update(array_merge(
$request->only([
'supplier_code', 'name', 'category', 'contact_person',
'email', 'secondary_email', 'phone', 'phone2', 'whatsapp',
'address', 'website', 'tax_number', 'credit_terms', 'credit_days', 'remarks',
]),
['is_active' => (bool) $request->input('is_active', 0)]
));
return redirect()->route('purchase.suppliers.index')->with('success', 'Supplier updated successfully.');
}
public function destroy(Supplier $supplier)
{
$supplier->delete();
return redirect()->route('purchase.suppliers.index')->with('success', 'Supplier deleted successfully.');
}
public function import(Request $request)
{
$request->validate([
'file' => 'required|file|mimes:xlsx,xls|max:10240',
]);
try {
$result = app(SupplierImportService::class)->import(
$request->file('file')->getPathname()
);
$msg = "Import complete: {$result['imported']} added, {$result['updated']} updated"
. ($result['skipped'] > 0 ? ", {$result['skipped']} skipped." : '.');
return redirect()->route('purchase.suppliers.index')->with('success', $msg);
} catch (\Exception $e) {
return redirect()->route('purchase.suppliers.index')
->with('error', 'Import failed: ' . $e->getMessage());
}
}
public function downloadTemplate()
{
$path = storage_path('app/suppliers_template.xlsx');
if (!file_exists($path)) {
Artisan::call('suppliers:template');
}
return response()->download($path, 'suppliers_import_template.xlsx');
}
public function exportPdf()
{
$suppliers = Supplier::orderBy('name')->get();
$pdf = Pdf::loadView('purchase.suppliers.pdf', compact('suppliers'))
->setPaper('a4', 'landscape');
return $pdf->download('suppliers_' . now()->format('Y-m-d') . '.pdf');
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller;
use App\Models\GoodsReceiptNote;
use App\Models\PurchaseOrder;
use App\Models\Supplier;
use App\Models\SupplierInvoice;
use Illuminate\Http\Request;
class SupplierInvoiceController extends Controller
{
public function index()
{
$invoices = SupplierInvoice::with('supplier')->paginate(15);
return view('purchase.invoices.index', compact('invoices'));
}
public function create()
{
$suppliers = Supplier::all();
$purchaseOrders = PurchaseOrder::where('status', 'received')->with('supplier')->get();
$grns = GoodsReceiptNote::where('status', 'confirmed')->with('purchaseOrder.supplier')->get();
return view('purchase.invoices.create', compact('suppliers', 'purchaseOrders', 'grns'));
}
public function store(Request $request)
{
$request->validate([
'supplier_id' => 'required|exists:suppliers,id',
'invoice_number' => 'required|string|max:255',
'invoice_date' => 'required|date',
'subtotal' => 'required|numeric|min:0',
'vat_amount' => 'required|numeric|min:0',
'total_amount' => 'required|numeric|min:0',
]);
SupplierInvoice::create(array_merge($request->all(), [
'status' => 'unpaid',
'paid_amount' => 0,
]));
return redirect()->route('purchase.invoices.index')->with('success', 'Supplier invoice created successfully.');
}
public function show(SupplierInvoice $supplierInvoice)
{
$supplierInvoice->load(['supplier', 'purchaseOrder']);
return view('purchase.invoices.show', compact('supplierInvoice'));
}
public function edit(SupplierInvoice $supplierInvoice)
{
$suppliers = Supplier::all();
return view('purchase.invoices.edit', compact('supplierInvoice', 'suppliers'));
}
public function update(Request $request, SupplierInvoice $supplierInvoice)
{
$request->validate([
'supplier_id' => 'required|exists:suppliers,id',
'invoice_number' => 'required|string|max:255',
'invoice_date' => 'required|date',
'subtotal' => 'required|numeric|min:0',
'vat_amount' => 'required|numeric|min:0',
'total_amount' => 'required|numeric|min:0',
]);
$supplierInvoice->update($request->all());
return redirect()->route('purchase.invoices.index')->with('success', 'Supplier invoice updated successfully.');
}
public function destroy(SupplierInvoice $supplierInvoice)
{
$supplierInvoice->delete();
return redirect()->route('purchase.invoices.index')->with('success', 'Supplier invoice deleted successfully.');
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller;
use App\Models\SupplierInvoice;
use App\Models\SupplierPayment;
use Illuminate\Http\Request;
class SupplierPaymentController extends Controller
{
public function index()
{
$payments = SupplierPayment::with('supplierInvoice.supplier')->paginate(15);
return view('purchase.payments.index', compact('payments'));
}
public function create()
{
$invoices = SupplierInvoice::whereIn('status', ['unpaid', 'partial'])->with('supplier')->get();
return view('purchase.payments.create', compact('invoices'));
}
public function store(Request $request)
{
$request->validate([
'supplier_invoice_id' => 'required|exists:supplier_invoices,id',
'payment_date' => 'required|date',
'amount' => 'required|numeric|min:0.01',
'payment_method' => 'required|string|max:255',
]);
$payment = SupplierPayment::create(array_merge($request->all(), [
'created_by' => auth()->id(),
]));
// Refresh invoice and recompute paid status
$invoice = SupplierInvoice::find($request->supplier_invoice_id);
$newPaidAmount = $invoice->paid_amount + $request->amount;
$status = $newPaidAmount >= $invoice->total_amount ? 'paid' : 'partial';
$invoice->update([
'paid_amount' => $newPaidAmount,
'status' => $status,
]);
return redirect()->route('purchase.payments.index')->with('success', 'Payment recorded successfully.');
}
public function show(SupplierPayment $supplierPayment)
{
$supplierPayment->load('supplierInvoice.supplier');
return view('purchase.payments.show', compact('supplierPayment'));
}
public function edit(SupplierPayment $supplierPayment)
{
$invoices = SupplierInvoice::whereIn('status', ['unpaid', 'partial'])->with('supplier')->get();
return view('purchase.payments.edit', compact('supplierPayment', 'invoices'));
}
public function update(Request $request, SupplierPayment $supplierPayment)
{
$request->validate([
'payment_date' => 'required|date',
'amount' => 'required|numeric|min:0.01',
'payment_method' => 'required|string|max:255',
]);
$supplierPayment->update($request->all());
return redirect()->route('purchase.payments.index')->with('success', 'Payment updated successfully.');
}
public function destroy(SupplierPayment $supplierPayment)
{
$supplierPayment->delete();
return redirect()->route('purchase.payments.index')->with('success', 'Payment deleted successfully.');
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller;
use App\Models\PurchaseRequest;
use App\Models\SupplierQuote;
use App\Services\PurchaseStageService;
use Illuminate\Http\Request;
class SupplierQuoteController extends Controller
{
public function index(PurchaseRequest $purchaseRequest)
{
$quotes = $purchaseRequest->supplierQuotes()->with('supplier', 'items')->get();
return view('purchase.quotes.index', ['request' => $purchaseRequest, 'quotes' => $quotes]);
}
public function compare(PurchaseRequest $purchaseRequest)
{
$quotes = $purchaseRequest->supplierQuotes()->with('supplier', 'items')->get();
$items = $purchaseRequest->items;
return view('purchase.quotes.compare', ['request' => $purchaseRequest, 'quotes' => $quotes, 'items' => $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.');
}
$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 awarded to ' . $quote->supplier->name . '. Ready to issue LPO.');
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Sales;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use Illuminate\Http\Request;
class CustomerController extends Controller
{
public function index()
{
$customers = Customer::paginate(15);
return view('sales.customers.index', compact('customers'));
}
public function create()
{
return view('sales.customers.create');
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'address' => 'nullable|string',
]);
Customer::create($request->all());
return redirect()->route('sales.customers.index')->with('success', 'Customer created successfully.');
}
public function show(Customer $customer)
{
return view('sales.customers.show', compact('customer'));
}
public function edit(Customer $customer)
{
return view('sales.customers.edit', compact('customer'));
}
public function update(Request $request, Customer $customer)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'address' => 'nullable|string',
]);
$customer->update($request->all());
return redirect()->route('sales.customers.index')->with('success', 'Customer updated successfully.');
}
public function destroy(Customer $customer)
{
$customer->delete();
return redirect()->route('sales.customers.index')->with('success', 'Customer deleted successfully.');
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\Sales;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\DeliveryNote;
use App\Models\DeliveryNoteItem;
use App\Models\SalesOrder;
use App\Models\SalesOrderItem;
use App\Models\StockLevel;
use App\Models\StockMovement;
use App\Models\Warehouse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DeliveryNoteController extends Controller
{
public function index()
{
$notes = DeliveryNote::with(['salesOrder.customer', 'warehouse'])->paginate(15);
return view('sales.delivery-notes.index', compact('notes'));
}
public function create()
{
$salesOrders = SalesOrder::where('status', 'confirmed')->with('customer')->get();
$warehouses = Warehouse::all();
return view('sales.delivery-notes.create', compact('salesOrders', 'warehouses'));
}
public function store(Request $request)
{
$request->validate([
'sales_order_id' => 'required|exists:sales_orders,id',
'warehouse_id' => 'required|exists:warehouses,id',
'delivery_date' => 'required|date',
'items' => 'required|array|min:1',
'items.*.item_id' => 'required|exists:items,id',
'items.*.quantity' => 'required|numeric|min:1',
]);
$deliveryNumber = 'DN-' . str_pad(DeliveryNote::max('id') + 1, 5, '0', STR_PAD_LEFT);
$note = DeliveryNote::create([
'delivery_number' => $deliveryNumber,
'sales_order_id' => $request->sales_order_id,
'warehouse_id' => $request->warehouse_id,
'delivery_date' => $request->delivery_date,
'status' => 'draft',
]);
$salesOrder = SalesOrder::with('items')->findOrFail($request->sales_order_id);
foreach ($request->items as $item) {
$soItem = $salesOrder->items->firstWhere('item_id', $item['item_id']);
DeliveryNoteItem::create([
'delivery_note_id' => $note->id,
'sales_order_item_id' => $soItem?->id ?? 0,
'item_id' => $item['item_id'],
'quantity_delivered' => $item['quantity'],
]);
}
return redirect()->route('sales.delivery-notes.show', $note)->with('success', 'Delivery note created successfully.');
}
public function show(DeliveryNote $deliveryNote)
{
$deliveryNote->load(['salesOrder.customer', 'warehouse', 'items.item']);
return view('sales.delivery-notes.show', compact('deliveryNote'));
}
public function edit(DeliveryNote $deliveryNote)
{
$warehouses = Warehouse::all();
return view('sales.delivery-notes.edit', compact('deliveryNote', 'warehouses'));
}
public function update(Request $request, DeliveryNote $deliveryNote)
{
$request->validate([
'delivery_date' => 'required|date',
'warehouse_id' => 'required|exists:warehouses,id',
]);
$deliveryNote->update($request->only('delivery_date', 'warehouse_id'));
return redirect()->route('sales.delivery-notes.show', $deliveryNote)->with('success', 'Delivery note updated successfully.');
}
public function destroy(DeliveryNote $deliveryNote)
{
$deliveryNote->delete();
return redirect()->route('sales.delivery-notes.index')->with('success', 'Delivery note deleted successfully.');
}
public function dispatch(DeliveryNote $deliveryNote)
{
DB::transaction(function () use ($deliveryNote) {
$deliveryNote->load(['items', 'salesOrder.items']);
foreach ($deliveryNote->items as $dnItem) {
$stockLevel = StockLevel::firstOrCreate(
['item_id' => $dnItem->item_id, 'warehouse_id' => $deliveryNote->warehouse_id],
['quantity' => 0]
);
$decrement = min($dnItem->quantity_delivered, $stockLevel->quantity);
$stockLevel->decrement('quantity', $decrement);
StockMovement::create([
'item_id' => $dnItem->item_id,
'warehouse_id' => $deliveryNote->warehouse_id,
'type' => 'out',
'quantity' => $dnItem->quantity_delivered,
'reference_type' => 'DeliveryNote',
'reference_id' => $deliveryNote->id,
'created_by' => auth()->id(),
]);
SalesOrderItem::where('sales_order_id', $deliveryNote->sales_order_id)
->where('item_id', $dnItem->item_id)
->increment('quantity_delivered', $dnItem->quantity_delivered);
}
$deliveryNote->update([
'status' => 'dispatched',
'dispatched_by' => auth()->id(),
]);
// Check if all sales order items are fully delivered
$salesOrder = $deliveryNote->salesOrder->fresh(['items']);
$allFullyDelivered = $salesOrder->items->every(
fn($soItem) => $soItem->quantity_delivered >= $soItem->quantity
);
if ($allFullyDelivered) {
$salesOrder->update(['status' => 'dispatched']);
}
});
return redirect()->back()->with('success', 'Delivery note dispatched and stock decremented.');
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Sales;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\PaymentReceipt;
use App\Models\SalesInvoice;
use Illuminate\Http\Request;
class PaymentReceiptController extends Controller
{
public function index()
{
$receipts = PaymentReceipt::with('salesInvoice.customer')->paginate(15);
return view('sales.payments.index', compact('receipts'));
}
public function create()
{
$invoices = SalesInvoice::whereIn('status', ['unpaid', 'partial'])->with('customer')->get();
$customers = Customer::all();
return view('sales.payments.create', compact('invoices', 'customers'));
}
public function store(Request $request)
{
$request->validate([
'sales_invoice_id' => 'required|exists:sales_invoices,id',
'receipt_date' => 'required|date',
'amount' => 'required|numeric|min:0.01',
'payment_method' => 'required|string|max:255',
]);
$receipt = PaymentReceipt::create(array_merge($request->all(), [
'created_by' => auth()->id(),
]));
$invoice = SalesInvoice::find($request->sales_invoice_id);
$newPaidAmount = $invoice->paid_amount + $request->amount;
$status = $newPaidAmount >= $invoice->total_amount ? 'paid' : 'partial';
$invoice->update([
'paid_amount' => $newPaidAmount,
'status' => $status,
]);
// Reduce the customer's outstanding balance by the amount received
$invoice->customer()->decrement('outstanding_balance', $request->amount);
return redirect()->route('sales.payments.index')->with('success', 'Payment receipt recorded successfully.');
}
public function show(PaymentReceipt $paymentReceipt)
{
$paymentReceipt->load('salesInvoice.customer');
return view('sales.payments.show', compact('paymentReceipt'));
}
public function edit(PaymentReceipt $paymentReceipt)
{
return view('sales.payments.edit', compact('paymentReceipt'));
}
public function update(Request $request, PaymentReceipt $paymentReceipt)
{
$request->validate([
'receipt_date' => 'required|date',
'payment_method' => 'required|string|max:255',
]);
$paymentReceipt->update($request->only('receipt_date', 'payment_method', 'notes'));
return redirect()->route('sales.payments.index')->with('success', 'Payment receipt updated successfully.');
}
public function destroy(PaymentReceipt $paymentReceipt)
{
$paymentReceipt->delete();
return redirect()->route('sales.payments.index')->with('success', 'Payment receipt deleted successfully.');
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Sales;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\SalesInvoice;
use App\Models\SalesOrder;
use Illuminate\Http\Request;
class SalesInvoiceController extends Controller
{
public function index()
{
$invoices = SalesInvoice::with('customer')->paginate(15);
return view('sales.invoices.index', compact('invoices'));
}
public function create()
{
$salesOrders = SalesOrder::where('status', 'dispatched')->with('customer')->get();
$customers = Customer::all();
return view('sales.invoices.create', compact('salesOrders', 'customers'));
}
public function store(Request $request)
{
$request->validate([
'sales_order_id' => 'required|exists:sales_orders,id',
'customer_id' => 'required|exists:customers,id',
'invoice_date' => 'required|date',
'subtotal' => 'required|numeric|min:0',
'vat_rate' => 'required|numeric|min:0',
'vat_amount' => 'required|numeric|min:0',
'total_amount' => 'required|numeric|min:0',
]);
$invoiceNumber = 'INV-' . str_pad(SalesInvoice::max('id') + 1, 5, '0', STR_PAD_LEFT);
SalesInvoice::create(array_merge($request->all(), [
'invoice_number' => $invoiceNumber,
'status' => 'unpaid',
'paid_amount' => 0,
'created_by' => auth()->id(),
]));
SalesOrder::where('id', $request->sales_order_id)->update(['status' => 'invoiced']);
return redirect()->route('sales.invoices.index')->with('success', 'Invoice created successfully.');
}
public function show(SalesInvoice $salesInvoice)
{
$salesInvoice->load(['customer', 'salesOrder', 'paymentReceipts']);
return view('sales.invoices.show', compact('salesInvoice'));
}
public function edit(SalesInvoice $salesInvoice)
{
$customers = Customer::all();
return view('sales.invoices.edit', compact('salesInvoice', 'customers'));
}
public function update(Request $request, SalesInvoice $salesInvoice)
{
$request->validate([
'invoice_date' => 'required|date',
'subtotal' => 'required|numeric|min:0',
'vat_rate' => 'required|numeric|min:0',
'vat_amount' => 'required|numeric|min:0',
'total_amount' => 'required|numeric|min:0',
]);
$salesInvoice->update($request->all());
return redirect()->route('sales.invoices.show', $salesInvoice)->with('success', 'Invoice updated successfully.');
}
public function destroy(SalesInvoice $salesInvoice)
{
$salesInvoice->delete();
return redirect()->route('sales.invoices.index')->with('success', 'Invoice deleted successfully.');
}
}

View File

@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Sales;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\Item;
use App\Models\SalesOrder;
use App\Models\SalesOrderItem;
use Illuminate\Http\Request;
class SalesOrderController extends Controller
{
public function index()
{
$orders = SalesOrder::with('customer')->paginate(15);
return view('sales.orders.index', compact('orders'));
}
public function create()
{
$customers = Customer::all();
$items = Item::where('category', 'finished_good')->get();
return view('sales.orders.create', compact('customers', 'items'));
}
public function store(Request $request)
{
$request->validate([
'customer_id' => 'required|exists:customers,id',
'order_date' => 'required|date',
'items' => 'required|array|min:1',
'items.*.item_id' => 'required|exists:items,id',
'items.*.quantity' => 'required|numeric|min:1',
'items.*.unit_price' => 'required|numeric|min:0',
]);
$orderNumber = 'SO-' . str_pad(SalesOrder::max('id') + 1, 5, '0', STR_PAD_LEFT);
$totalAmount = collect($request->items)->sum(fn($item) => $item['quantity'] * $item['unit_price']);
$order = SalesOrder::create([
'order_number' => $orderNumber,
'customer_id' => $request->customer_id,
'order_date' => $request->order_date,
'total_amount' => $totalAmount,
'status' => 'draft',
'created_by' => auth()->id(),
]);
foreach ($request->items as $item) {
SalesOrderItem::create([
'sales_order_id' => $order->id,
'item_id' => $item['item_id'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'amount' => $item['quantity'] * $item['unit_price'],
'quantity_delivered' => 0,
]);
}
return redirect()->route('sales.orders.show', $order)->with('success', 'Sales order created successfully.');
}
public function show(SalesOrder $salesOrder)
{
$salesOrder->load(['customer', 'items.item', 'deliveryNotes', 'invoices']);
return view('sales.orders.show', compact('salesOrder'));
}
public function edit(SalesOrder $salesOrder)
{
$customers = Customer::all();
$items = Item::where('category', 'finished_good')->get();
return view('sales.orders.edit', compact('salesOrder', 'customers', 'items'));
}
public function update(Request $request, SalesOrder $salesOrder)
{
$request->validate([
'customer_id' => 'required|exists:customers,id',
'order_date' => 'required|date',
]);
$salesOrder->update($request->only('customer_id', 'order_date', 'notes'));
return redirect()->route('sales.orders.show', $salesOrder)->with('success', 'Sales order updated successfully.');
}
public function destroy(SalesOrder $salesOrder)
{
$salesOrder->delete();
return redirect()->route('sales.orders.index')->with('success', 'Sales order deleted successfully.');
}
public function confirm(SalesOrder $salesOrder)
{
$salesOrder->update(['status' => 'confirmed']);
return redirect()->back()->with('success', 'Sales order confirmed.');
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Mail;
use App\Models\RfqInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class RfqInvitationMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public RfqInvitation $invitation) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Quote Request — ' . $this->invitation->purchaseRequest->request_number,
);
}
public function content(): Content
{
return new Content(view: 'mail.rfq-invitation');
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class BillOfMaterial extends Model
{
use HasFactory;
protected $table = 'bill_of_materials';
protected $fillable = ['product_id', 'raw_material_id', 'quantity_required', 'unit_of_measure', 'notes'];
protected $casts = ['quantity_required' => 'decimal:2'];
public function product()
{
return $this->belongsTo(Item::class, 'product_id');
}
public function rawMaterial()
{
return $this->belongsTo(Item::class, 'raw_material_id');
}
}

34
app/Models/Customer.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Customer extends Model
{
use HasFactory;
protected $fillable = ['name', 'contact_person', 'email', 'phone', 'address', 'tax_number', 'credit_limit', 'outstanding_balance', 'is_active'];
protected $casts = [
'credit_limit' => 'decimal:2',
'outstanding_balance' => 'decimal:2',
'is_active' => 'boolean',
];
public function salesOrders()
{
return $this->hasMany(SalesOrder::class);
}
public function salesInvoices()
{
return $this->hasMany(SalesInvoice::class);
}
public function paymentReceipts()
{
return $this->hasMany(PaymentReceipt::class);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class DeliveryNote extends Model
{
use HasFactory;
protected $fillable = ['delivery_number', 'sales_order_id', 'customer_id', 'warehouse_id', 'delivery_date', 'status', 'notes', 'dispatched_by'];
protected $casts = ['delivery_date' => 'date'];
public function salesOrder()
{
return $this->belongsTo(SalesOrder::class);
}
public function customer()
{
return $this->belongsTo(Customer::class);
}
public function warehouse()
{
return $this->belongsTo(Warehouse::class);
}
public function items()
{
return $this->hasMany(DeliveryNoteItem::class);
}
public function dispatchedBy()
{
return $this->belongsTo(\App\Models\User::class, 'dispatched_by');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class DeliveryNoteItem extends Model
{
use HasFactory;
protected $fillable = ['delivery_note_id', 'sales_order_item_id', 'item_id', 'quantity_delivered'];
protected $casts = ['quantity_delivered' => 'decimal:2'];
public function deliveryNote()
{
return $this->belongsTo(DeliveryNote::class);
}
public function salesOrderItem()
{
return $this->belongsTo(SalesOrderItem::class);
}
public function item()
{
return $this->belongsTo(Item::class);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class GoodsReceiptNote extends Model
{
use HasFactory;
protected $fillable = ['grn_number', 'purchase_order_id', 'supplier_id', 'warehouse_id', 'received_date', 'status', 'notes', 'received_by'];
protected $casts = ['received_date' => 'date'];
public function purchaseOrder()
{
return $this->belongsTo(PurchaseOrder::class);
}
public function supplier()
{
return $this->belongsTo(Supplier::class);
}
public function warehouse()
{
return $this->belongsTo(Warehouse::class);
}
public function items()
{
return $this->hasMany(GrnItem::class);
}
public function receivedBy()
{
return $this->belongsTo(\App\Models\User::class, 'received_by');
}
}

33
app/Models/GrnItem.php Normal file
View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class GrnItem extends Model
{
use HasFactory;
protected $fillable = ['goods_receipt_note_id', 'purchase_order_item_id', 'item_id', 'quantity_received', 'unit_cost', 'type'];
protected $casts = [
'quantity_received' => 'decimal:2',
'unit_cost' => 'decimal:2',
];
public function goodsReceiptNote()
{
return $this->belongsTo(GoodsReceiptNote::class);
}
public function purchaseOrderItem()
{
return $this->belongsTo(PurchaseOrderItem::class);
}
public function item()
{
return $this->belongsTo(Item::class);
}
}

39
app/Models/Item.php Normal file
View File

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Item extends Model
{
use HasFactory;
protected $fillable = ['item_code', 'item_name', 'category', 'unit_of_measure', 'minimum_stock_level', 'cost_price', 'description', 'is_active'];
protected $casts = [
'minimum_stock_level' => 'decimal:2',
'cost_price' => 'decimal:2',
'is_active' => 'boolean',
];
public function stockLevels()
{
return $this->hasMany(StockLevel::class);
}
public function stockMovements()
{
return $this->hasMany(StockMovement::class);
}
public function billOfMaterials()
{
return $this->hasMany(BillOfMaterial::class, 'product_id');
}
public function bomComponents()
{
return $this->hasMany(BillOfMaterial::class, 'raw_material_id');
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class MaterialIssue extends Model
{
use HasFactory;
protected $fillable = ['issue_number', 'production_order_id', 'item_id', 'warehouse_id', 'quantity', 'issue_date', 'notes', 'issued_by'];
protected $casts = [
'issue_date' => 'date',
'quantity' => 'decimal:2',
];
public function productionOrder()
{
return $this->belongsTo(ProductionOrder::class);
}
public function item()
{
return $this->belongsTo(Item::class);
}
public function warehouse()
{
return $this->belongsTo(Warehouse::class);
}
public function issuedBy()
{
return $this->belongsTo(\App\Models\User::class, 'issued_by');
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PaymentReceipt extends Model
{
use HasFactory;
protected $fillable = ['sales_invoice_id', 'customer_id', 'receipt_date', 'amount', 'payment_method', 'reference_number', 'notes', 'created_by'];
protected $casts = [
'receipt_date' => 'date',
'amount' => 'decimal:2',
];
public function salesInvoice()
{
return $this->belongsTo(SalesInvoice::class);
}
public function customer()
{
return $this->belongsTo(Customer::class);
}
public function createdBy()
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ProductionCost extends Model
{
use HasFactory;
protected $fillable = ['production_order_id', 'raw_material_cost', 'labor_cost', 'machine_cost', 'overhead_cost', 'total_cost', 'notes'];
protected $casts = [
'raw_material_cost' => 'decimal:2',
'labor_cost' => 'decimal:2',
'machine_cost' => 'decimal:2',
'overhead_cost' => 'decimal:2',
'total_cost' => 'decimal:2',
];
public function productionOrder()
{
return $this->belongsTo(ProductionOrder::class);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ProductionOrder extends Model
{
use HasFactory;
protected $fillable = ['order_number', 'product_id', 'quantity_to_produce', 'quantity_produced', 'production_date', 'completion_date', 'status', 'notes', 'created_by'];
protected $casts = [
'production_date' => 'date',
'completion_date' => 'date',
'quantity_to_produce' => 'decimal:2',
'quantity_produced' => 'decimal:2',
];
public function product()
{
return $this->belongsTo(Item::class, 'product_id');
}
public function materialIssues()
{
return $this->hasMany(MaterialIssue::class);
}
public function outputs()
{
return $this->hasMany(ProductionOutput::class);
}
public function cost()
{
return $this->hasOne(ProductionCost::class);
}
public function createdBy()
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ProductionOutput extends Model
{
use HasFactory;
protected $fillable = ['production_order_id', 'item_id', 'warehouse_id', 'quantity', 'output_date', 'notes', 'recorded_by'];
protected $casts = [
'output_date' => 'date',
'quantity' => 'decimal:2',
];
public function productionOrder()
{
return $this->belongsTo(ProductionOrder::class);
}
public function item()
{
return $this->belongsTo(Item::class);
}
public function warehouse()
{
return $this->belongsTo(Warehouse::class);
}
public function recordedBy()
{
return $this->belongsTo(\App\Models\User::class, 'recorded_by');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PurchaseOrder extends Model
{
use HasFactory;
protected $fillable = ['po_number', 'supplier_id', 'purchase_request_id', 'po_date', 'expected_delivery_date', 'total_amount', 'status', 'notes', 'created_by'];
protected $casts = [
'po_date' => 'date',
'expected_delivery_date' => 'date',
'total_amount' => 'decimal:2',
];
public function supplier()
{
return $this->belongsTo(Supplier::class);
}
public function purchaseRequest()
{
return $this->belongsTo(PurchaseRequest::class);
}
public function items()
{
return $this->hasMany(PurchaseOrderItem::class);
}
public function goodsReceiptNotes()
{
return $this->hasMany(GoodsReceiptNote::class);
}
public function supplierInvoices()
{
return $this->hasMany(SupplierInvoice::class);
}
public function createdBy()
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PurchaseOrderItem extends Model
{
use HasFactory;
protected $fillable = ['purchase_order_id', 'item_id', 'quantity', 'rate', 'total_amount', 'quantity_received'];
protected $casts = [
'quantity' => 'decimal:2',
'rate' => 'decimal:2',
'total_amount' => 'decimal:2',
'quantity_received' => 'decimal:2',
];
public function purchaseOrder()
{
return $this->belongsTo(PurchaseOrder::class);
}
public function item()
{
return $this->belongsTo(Item::class);
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PurchaseRequest extends Model
{
use HasFactory;
protected $fillable = [
'request_number', 'date', 'project_name', 'department',
'requested_by_name', 'required_date_text', 'location',
'remarks', 'status', 'stage', 'verified_by_name',
'requested_by', 'approved_by', 'approved_at',
];
protected $casts = [
'date' => 'date',
'approved_at' => 'datetime',
];
public function items()
{
return $this->hasMany(PurchaseRequestItem::class);
}
public function requestedBy()
{
return $this->belongsTo(User::class, 'requested_by');
}
public function approvedBy()
{
return $this->belongsTo(User::class, 'approved_by');
}
public function purchaseOrders()
{
return $this->hasMany(PurchaseOrder::class);
}
public function signature()
{
return $this->hasOne(PurchaseSignature::class);
}
public function rfqInvitations()
{
return $this->hasMany(RfqInvitation::class);
}
public function supplierQuotes()
{
return $this->hasMany(SupplierQuote::class);
}
public function awardedQuote()
{
return $this->hasOne(SupplierQuote::class)->where('is_awarded', true);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PurchaseRequestItem extends Model
{
use HasFactory;
protected $fillable = [
'purchase_request_id', 'description', 'unit',
'quantity_required', 'purpose_use', 'required_date',
];
protected $casts = [
'required_date' => 'date',
'quantity_required' => 'decimal:2',
];
public function purchaseRequest()
{
return $this->belongsTo(PurchaseRequest::class);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PurchaseSignature extends Model
{
protected $fillable = [
'purchase_request_id', 'signed_by', 'signature_image', 'signed_at', 'ip_address',
];
protected $casts = ['signed_at' => 'datetime'];
public function purchaseRequest()
{
return $this->belongsTo(PurchaseRequest::class);
}
public function signedBy()
{
return $this->belongsTo(User::class, 'signed_by');
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class RfqInvitation extends Model
{
protected $fillable = [
'purchase_request_id', 'supplier_id', 'token', 'channel',
'sent_at', 'opened_at', 'expires_at', 'status', 'item_ids',
];
protected $casts = [
'sent_at' => 'datetime',
'opened_at' => 'datetime',
'expires_at' => 'datetime',
'item_ids' => 'array',
];
public function purchaseRequest()
{
return $this->belongsTo(PurchaseRequest::class);
}
public function supplier()
{
return $this->belongsTo(Supplier::class);
}
public function quote()
{
return $this->hasOne(SupplierQuote::class);
}
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at->isPast();
}
public function isSubmitted(): bool
{
return $this->status === 'submitted';
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SalesInvoice extends Model
{
use HasFactory;
protected $fillable = ['invoice_number', 'sales_order_id', 'customer_id', 'invoice_date', 'due_date', 'subtotal', 'vat_rate', 'vat_amount', 'total_amount', 'paid_amount', 'status', 'notes', 'created_by'];
protected $casts = [
'invoice_date' => 'date',
'due_date' => 'date',
'subtotal' => 'decimal:2',
'vat_rate' => 'decimal:2',
'vat_amount' => 'decimal:2',
'total_amount' => 'decimal:2',
'paid_amount' => 'decimal:2',
];
public function salesOrder()
{
return $this->belongsTo(SalesOrder::class);
}
public function customer()
{
return $this->belongsTo(Customer::class);
}
public function paymentReceipts()
{
return $this->hasMany(PaymentReceipt::class);
}
public function createdBy()
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
public function getOutstandingAttribute()
{
return $this->total_amount - $this->paid_amount;
}
}

44
app/Models/SalesOrder.php Normal file
View File

@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SalesOrder extends Model
{
use HasFactory;
protected $fillable = ['order_number', 'customer_id', 'order_date', 'delivery_date', 'total_amount', 'status', 'notes', 'created_by'];
protected $casts = [
'order_date' => 'date',
'delivery_date' => 'date',
'total_amount' => 'decimal:2',
];
public function customer()
{
return $this->belongsTo(Customer::class);
}
public function items()
{
return $this->hasMany(SalesOrderItem::class);
}
public function deliveryNotes()
{
return $this->hasMany(DeliveryNote::class);
}
public function invoices()
{
return $this->hasMany(SalesInvoice::class);
}
public function createdBy()
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SalesOrderItem extends Model
{
use HasFactory;
protected $fillable = ['sales_order_id', 'item_id', 'quantity', 'price', 'total_amount', 'quantity_delivered'];
protected $casts = [
'quantity' => 'decimal:2',
'price' => 'decimal:2',
'total_amount' => 'decimal:2',
'quantity_delivered' => 'decimal:2',
];
public function salesOrder()
{
return $this->belongsTo(SalesOrder::class);
}
public function item()
{
return $this->belongsTo(Item::class);
}
}

25
app/Models/StockLevel.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class StockLevel extends Model
{
use HasFactory;
protected $fillable = ['item_id', 'warehouse_id', 'quantity'];
protected $casts = ['quantity' => 'decimal:2'];
public function item()
{
return $this->belongsTo(Item::class);
}
public function warehouse()
{
return $this->belongsTo(Warehouse::class);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class StockMovement extends Model
{
use HasFactory;
protected $fillable = ['item_id', 'warehouse_id', 'type', 'quantity', 'reference_type', 'reference_id', 'notes', 'created_by'];
protected $casts = ['quantity' => 'decimal:2'];
public function item()
{
return $this->belongsTo(Item::class);
}
public function warehouse()
{
return $this->belongsTo(Warehouse::class);
}
public function createdBy()
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
}

37
app/Models/Supplier.php Normal file
View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Supplier extends Model
{
use HasFactory;
protected $fillable = [
'supplier_code', 'name', 'category',
'contact_person', 'email', 'secondary_email',
'phone', 'phone2', 'whatsapp',
'address', 'website',
'tax_number', 'credit_terms', 'credit_days',
'is_active', 'remarks',
];
protected $casts = ['is_active' => 'boolean'];
public function purchaseOrders()
{
return $this->hasMany(PurchaseOrder::class);
}
public function invoices()
{
return $this->hasMany(SupplierInvoice::class);
}
public function payments()
{
return $this->hasMany(SupplierPayment::class);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SupplierInvoice extends Model
{
use HasFactory;
protected $fillable = ['invoice_number', 'supplier_id', 'purchase_order_id', 'goods_receipt_note_id', 'invoice_date', 'due_date', 'subtotal', 'vat_amount', 'total_amount', 'paid_amount', 'status', 'notes'];
protected $casts = [
'invoice_date' => 'date',
'due_date' => 'date',
'subtotal' => 'decimal:2',
'vat_amount' => 'decimal:2',
'total_amount' => 'decimal:2',
'paid_amount' => 'decimal:2',
];
public function supplier()
{
return $this->belongsTo(Supplier::class);
}
public function purchaseOrder()
{
return $this->belongsTo(PurchaseOrder::class);
}
public function goodsReceiptNote()
{
return $this->belongsTo(GoodsReceiptNote::class);
}
public function payments()
{
return $this->hasMany(SupplierPayment::class);
}
public function getOutstandingAttribute()
{
return $this->total_amount - $this->paid_amount;
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SupplierPayment extends Model
{
use HasFactory;
protected $fillable = ['supplier_invoice_id', 'supplier_id', 'payment_date', 'amount', 'payment_method', 'reference_number', 'notes', 'created_by'];
protected $casts = [
'payment_date' => 'date',
'amount' => 'decimal:2',
];
public function supplierInvoice()
{
return $this->belongsTo(SupplierInvoice::class);
}
public function supplier()
{
return $this->belongsTo(Supplier::class);
}
public function createdBy()
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SupplierQuote extends Model
{
protected $fillable = [
'rfq_invitation_id', 'purchase_request_id', 'supplier_id',
'submitted_at', 'lead_time_days', 'payment_terms', 'notes',
'total_amount', 'is_awarded', 'award_reason', 'awarded_at', 'awarded_by',
];
protected $casts = [
'submitted_at' => 'datetime',
'awarded_at' => 'datetime',
'is_awarded' => 'boolean',
];
public function rfqInvitation()
{
return $this->belongsTo(RfqInvitation::class);
}
public function purchaseRequest()
{
return $this->belongsTo(PurchaseRequest::class);
}
public function supplier()
{
return $this->belongsTo(Supplier::class);
}
public function items()
{
return $this->hasMany(SupplierQuoteItem::class);
}
public function awardedBy()
{
return $this->belongsTo(User::class, 'awarded_by');
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SupplierQuoteItem extends Model
{
protected $fillable = [
'supplier_quote_id', 'description', 'unit', 'quantity', 'unit_price', 'total_price',
];
public function quote()
{
return $this->belongsTo(SupplierQuote::class, 'supplier_quote_id');
}
}

50
app/Models/User.php Normal file
View File

@ -0,0 +1,50 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, HasRoles;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

25
app/Models/Warehouse.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Warehouse extends Model
{
use HasFactory;
protected $fillable = ['code', 'name', 'location', 'description', 'is_active'];
protected $casts = ['is_active' => 'boolean'];
public function stockLevels()
{
return $this->hasMany(StockLevel::class);
}
public function stockMovements()
{
return $this->hasMany(StockMovement::class);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@ -0,0 +1,197 @@
<?php
namespace App\Services;
use App\Models\Item;
use PhpOffice\PhpSpreadsheet\IOFactory;
class ItemImportService
{
/** Section-header text → category value */
private array $sectionMap = [
'material inventory for sale' => 'finished_good',
'chemical materials' => 'raw_material',
'natural pigments' => 'raw_material',
'raw materials' => 'raw_material',
'forkoll bags' => 'raw_material',
'others' => 'raw_material',
];
/** Rows whose column-A value should be skipped entirely */
private array $skipWords = ['sub total', 'subtotal', 'total', 'si. no', 'si no', 'sl. no', 'sl no', 's.no'];
/** Col-B values that indicate a header row, not data */
private array $headerWords = ['description', 'item name', 'item_name', 'material'];
public function import(string $filePath): array
{
$spreadsheet = IOFactory::load($filePath);
$format = $this->detectFormat($spreadsheet);
$rows = $format === 'forkoll'
? $this->extractFromForkoll($spreadsheet)
: $this->extractFromTemplate($spreadsheet);
$imported = 0;
$skipped = 0;
$codeSeq = (Item::max('id') ?? 0) + 1;
foreach ($rows as $data) {
$name = trim($data['item_name'] ?? '');
if (empty($name)) {
continue;
}
$exists = Item::whereRaw('LOWER(item_name) = ?', [strtolower($name)])->exists();
if ($exists) {
$skipped++;
continue;
}
$active = !str_contains(strtolower($name), 'not in use');
$itemCode = $data['item_code'] ?? ('ITEM-' . str_pad($codeSeq, 5, '0', STR_PAD_LEFT));
$codeSeq++;
Item::create([
'item_code' => $itemCode,
'item_name' => $name,
'category' => $data['category'] ?? 'finished_good',
'unit_of_measure' => $data['unit_of_measure'] ?? 'EA',
'cost_price' => $data['cost_price'] ?? 0,
'minimum_stock_level' => $data['minimum_stock_level'] ?? 0,
'description' => $data['description'] ?? null,
'is_active' => $data['is_active'] ?? $active,
]);
$imported++;
}
return ['imported' => $imported, 'skipped' => $skipped, 'format' => $format];
}
private function detectFormat(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): string
{
$sheet = $spreadsheet->getActiveSheet();
// Forkoll sheets have "MATERIAL INVENTORY FOR SALE" somewhere in col A around row 3
for ($r = 1; $r <= 5; $r++) {
$val = strtolower(trim((string) $sheet->getCell('A' . $r)->getValue()));
if (str_contains($val, 'material inventory for sale') || str_contains($val, 'forkoll')) {
return 'forkoll';
}
}
return 'template';
}
private function extractFromForkoll(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
{
$sheet = $spreadsheet->getActiveSheet();
$maxRow = $sheet->getHighestRow();
$items = [];
$category = 'finished_good';
for ($r = 1; $r <= $maxRow; $r++) {
$colA = trim((string) $sheet->getCell('A' . $r)->getValue());
$colB = trim((string) $sheet->getCell('B' . $r)->getValue());
$colC = trim((string) $sheet->getCell('C' . $r)->getValue());
$colD = trim((string) $sheet->getCell('D' . $r)->getValue());
// Detect section header (text in col A, empty col B)
if ($colA !== '' && $colB === '') {
$lower = strtolower($colA);
// Skip SUB TOTAL rows
foreach ($this->skipWords as $skip) {
if (str_contains($lower, $skip)) {
continue 2;
}
}
// Update current category from section map
foreach ($this->sectionMap as $keyword => $cat) {
if (str_contains($lower, $keyword)) {
$category = $cat;
break;
}
}
continue;
}
// Skip rows with no description or a formula
if ($colB === '' || str_starts_with($colB, '=')) {
continue;
}
// Skip header rows (col B says "DESCRIPTION", "Item Name", etc.)
if (in_array(strtolower($colB), $this->headerWords)) {
continue;
}
$price = is_numeric($colD) ? (float) $colD : 0.0;
$items[] = [
'item_name' => $colB,
'unit_of_measure' => $colC ?: 'EA',
'category' => $category,
'cost_price' => $price,
'is_active' => !str_contains(strtolower($colB), 'not in use'),
];
}
return $items;
}
private function extractFromTemplate(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
{
$rows = $spreadsheet->getActiveSheet()->toArray(null, true, true, false);
$headers = array_map(
fn($h) => strtolower(trim(str_replace('*', '', (string) $h))),
$rows[0] ?? []
);
$fields = ['item_name', 'unit_of_measure', 'category', 'cost_price', 'minimum_stock_level', 'description', 'is_active'];
$map = [];
foreach ($fields as $field) {
$idx = array_search($field, $headers);
if ($idx !== false) {
$map[$field] = $idx;
}
}
if (!isset($map['item_name'])) {
return [];
}
$items = [];
foreach (array_slice($rows, 1) as $row) {
$name = trim((string) ($row[$map['item_name']] ?? ''));
if (empty($name) || str_starts_with($name, '*')) {
continue;
}
$entry = ['item_name' => $name];
foreach (array_diff($fields, ['item_name']) as $field) {
if (isset($map[$field])) {
$val = trim((string) ($row[$map[$field]] ?? ''));
$entry[$field] = $val !== '' ? $val : null;
}
}
if (isset($entry['cost_price'])) {
$entry['cost_price'] = is_numeric($entry['cost_price']) ? (float) $entry['cost_price'] : 0;
}
if (isset($entry['minimum_stock_level'])) {
$entry['minimum_stock_level'] = is_numeric($entry['minimum_stock_level']) ? (float) $entry['minimum_stock_level'] : 0;
}
if (isset($entry['is_active'])) {
$entry['is_active'] = in_array(strtolower($entry['is_active'] ?? ''), ['yes', 'true', '1', 'active', 'y']);
}
$items[] = $entry;
}
return $items;
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Services;
use App\Models\PurchaseRequest;
class PurchaseStageService
{
const STAGES = [
'draft', 'gm_approval', 'rfq', 'quoting',
'comparison', 'lpo', 'receiving', 'payment', 'complete',
];
public function advance(PurchaseRequest $request): void
{
$current = array_search($request->stage, self::STAGES);
if ($current === false || $current === count(self::STAGES) - 1) {
return;
}
$request->update(['stage' => self::STAGES[$current + 1]]);
}
public function setStage(PurchaseRequest $request, string $stage): void
{
abort_unless(in_array($stage, self::STAGES), 422, 'Invalid stage');
$request->update(['stage' => $stage]);
}
public function stageIndex(string $stage): int
{
$idx = array_search($stage, self::STAGES);
return $idx === false ? 0 : $idx;
}
public function stageLabel(string $stage): string
{
return match ($stage) {
'draft' => 'Purchase Request',
'gm_approval' => 'GM Signature',
'rfq' => 'Select Suppliers',
'quoting' => 'Awaiting Quotes',
'comparison' => 'Quote Comparison',
'lpo' => 'LPO Issued',
'receiving' => 'Receiving Materials',
'payment' => 'Payment',
'complete' => 'Complete',
default => ucfirst($stage),
};
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Services;
use App\Mail\RfqInvitationMail;
use App\Models\PurchaseRequest;
use App\Models\RfqInvitation;
use App\Models\Supplier;
use Illuminate\Support\Facades\Mail;
class RfqInvitationService
{
public function select(PurchaseRequest $purchaseRequest, Supplier $supplier, string $channel, array $itemIds = []): RfqInvitation
{
return RfqInvitation::create([
'purchase_request_id' => $purchaseRequest->id,
'supplier_id' => $supplier->id,
'token' => bin2hex(random_bytes(32)),
'channel' => $channel,
'expires_at' => now()->addDays(14),
'status' => 'pending',
'item_ids' => empty($itemIds) ? null : $itemIds,
]);
}
public function sendInvitation(RfqInvitation $invitation): void
{
$invitation->update(['status' => 'sent', 'sent_at' => now()]);
$supplier = $invitation->supplier;
if (in_array($invitation->channel, ['email', 'both']) && $supplier->email) {
try {
Mail::to($supplier->email)->send(new RfqInvitationMail($invitation));
} catch (\Exception $e) {
// Mail failure should not block flow
}
}
}
public function invite(PurchaseRequest $purchaseRequest, Supplier $supplier, string $channel): RfqInvitation
{
$invitation = $this->select($purchaseRequest, $supplier, $channel);
$this->sendInvitation($invitation);
return $invitation;
}
public function whatsappLink(RfqInvitation $invitation): string
{
$url = route('rfq.show', $invitation->token);
$text = "Hello {$invitation->supplier->name},\n\n"
. "You are invited to submit a quote for purchase request {$invitation->purchaseRequest->request_number}.\n\n"
. "Please click the link below to submit your quote:\n{$url}\n\n"
. "This link expires in 7 days and can only be used once.";
$phone = preg_replace('/\D/', '', $invitation->supplier->phone ?? '');
return 'https://wa.me/' . $phone . '?text=' . rawurlencode($text);
}
}

View File

@ -0,0 +1,272 @@
<?php
namespace App\Services;
use App\Models\Supplier;
use PhpOffice\PhpSpreadsheet\IOFactory;
class SupplierImportService
{
private array $mrfStopWords = ['comments if any', 'lowest price', 'avg price', 'recommendation'];
public function import(string $filePath): array
{
$spreadsheet = IOFactory::load($filePath);
$format = $this->detectFormat($spreadsheet);
$rows = $format === 'mrf'
? $this->extractFromMrf($spreadsheet)
: $this->extractFromTemplate($spreadsheet);
$imported = 0;
$updated = 0;
$skipped = 0;
foreach ($rows as $data) {
$name = trim($data['name'] ?? '');
if (empty($name)) {
continue;
}
$existing = Supplier::whereRaw('LOWER(name) = ?', [strtolower($name)])->first();
if ($existing) {
// Update the existing record with any new/missing fields
$existing->update($this->buildAttributes($data, true));
$updated++;
} else {
Supplier::create($this->buildAttributes($data, false));
$imported++;
}
}
return [
'imported' => $imported,
'updated' => $updated,
'skipped' => $skipped,
'format' => $format,
];
}
private function buildAttributes(array $data, bool $isUpdate): array
{
$attrs = [
'contact_person' => $this->str($data['contact_person'] ?? ''),
'email' => $this->str($data['email'] ?? ''),
'secondary_email' => $this->str($data['secondary_email'] ?? ''),
'phone' => $this->normalizePhone($data['phone'] ?? ''),
'phone2' => $this->normalizePhone($data['phone2'] ?? ''),
'whatsapp' => $this->normalizePhone($data['whatsapp'] ?? ''),
'address' => $this->str($data['address'] ?? ''),
'website' => $this->str($data['website'] ?? ''),
'tax_number' => $this->str($data['tax_number'] ?? ''),
'credit_terms' => $this->str($data['credit_terms'] ?? ''),
'credit_days' => $this->parseCreditDays($data['credit_days'] ?? ''),
'remarks' => $this->str($data['remarks'] ?? ''),
'is_active' => $this->parseBoolean($data['is_active'] ?? 'yes'),
];
if (!$isUpdate) {
$attrs['name'] = trim($data['name']);
$attrs['supplier_code'] = $this->str($data['supplier_code'] ?? '');
$attrs['category'] = $this->str($data['category'] ?? '');
} else {
// On update: only fill category/code if not already set
if (!empty($data['supplier_code'])) {
$attrs['supplier_code'] = $this->str($data['supplier_code']);
}
if (!empty($data['category'])) {
$attrs['category'] = $this->str($data['category']);
}
}
// Remove null-equivalent values so we don't overwrite real data with blanks
return array_filter($attrs, fn($v) => $v !== null && $v !== '');
}
// ── Format detection ────────────────────────────────────────────────────
private function detectFormat(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): string
{
$sheet = $spreadsheet->getActiveSheet();
$a4 = strtolower(trim((string) $sheet->getCell('A4')->getValue()));
return $a4 === 's.no' ? 'mrf' : 'template';
}
// ── MRF extraction ──────────────────────────────────────────────────────
private function extractFromMrf(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
{
$sheet = $spreadsheet->getActiveSheet();
$suppliers = [];
for ($col = 7; $col <= 50; $col++) {
$coord = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($col) . '4';
$value = trim((string) $sheet->getCell($coord)->getValue());
if (empty($value) || in_array(strtolower($value), $this->mrfStopWords)) {
break;
}
$suppliers[] = ['name' => $value];
}
return $suppliers;
}
// ── Template / Unified format extraction ────────────────────────────────
private function extractFromTemplate(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
{
$rows = $spreadsheet->getActiveSheet()->toArray(null, true, true, false);
$headers = array_map(
fn($h) => strtolower(trim(str_replace(['*', '_'], [' ', ' '], (string) $h))),
$rows[0] ?? []
);
// Map normalised header text → internal field name
$aliases = [
'supplier id' => 'supplier_code',
'name' => 'name',
'company name' => 'name',
'category' => 'category',
'contact person' => 'contact_person',
'primary email' => 'email',
'email' => 'email',
'secondary email' => 'secondary_email',
'phone 1' => 'phone',
'phone' => 'phone',
'phone 2' => 'phone2',
'whatsapp number' => 'whatsapp',
'whatsapp' => 'whatsapp',
'address' => 'address',
'website' => 'website',
'credit (y/n)' => 'credit_terms',
'credit days' => 'credit_days',
'tax number' => 'tax_number',
'is active' => 'is_active',
'remarks / key details' => 'remarks',
'remarks' => 'remarks',
];
$map = [];
foreach ($headers as $idx => $header) {
if (isset($aliases[$header]) && !isset($map[$aliases[$header]])) {
$map[$aliases[$header]] = $idx;
}
}
if (!isset($map['name'])) {
return [];
}
$suppliers = [];
foreach (array_slice($rows, 1) as $row) {
$name = trim((string) ($row[$map['name']] ?? ''));
if (empty($name) || str_starts_with($name, '*')) {
continue;
}
if (!$this->looksLikeSupplierName($name)) {
continue;
}
$entry = ['name' => $name];
foreach (array_keys($aliases) as $alias) {
$field = $aliases[$alias];
if ($field === 'name') continue;
if (isset($map[$field]) && !isset($entry[$field])) {
$entry[$field] = trim((string) ($row[$map[$field]] ?? ''));
}
}
$suppliers[] = $entry;
}
return $suppliers;
}
// ── Helpers ─────────────────────────────────────────────────────────────
/**
* Reject rows that are specification text, not company names.
* Patterns: starts with digit, measurement strings, known junk phrases.
*/
private function looksLikeSupplierName(string $name): bool
{
// Starts with a digit → quantity/spec row
if (preg_match('/^\d/', $name)) {
return false;
}
$lower = strtolower($name);
$junkPhrases = [
'gdcd minimum', 'approx. quantity', 'approx quantity',
'per 200 m', 'per 30 m', 'coverage mandatory',
'hydrant within', 'smoke extraction', 'mandatory for',
'required for buildings', '', '—',
];
foreach ($junkPhrases as $phrase) {
if (str_contains($lower, $phrase)) {
return false;
}
}
return true;
}
/**
* Normalise phone numbers to +COUNTRYCODELOCAL (digits only after +).
* Examples: "+973 3318 8311" "+97333188311"
* "39209304" (8-digit Bahrain local) "+97339209304"
*/
public function normalizePhone(string $raw): string
{
$raw = trim($raw);
if ($raw === '') {
return '';
}
// Strip parenthetical extensions: (ext 118), (ext 201, 202)
$val = preg_replace('/\(\s*ext[^)]*\)/i', '', $raw);
// Strip trailing extensions: " Ext. 7438", "x 26", "- 26" (1-4 digits at end after dash/space)
$val = preg_replace('/\s+(?:ext\.?|x)\s*[\d,\s]+$/i', '', $val);
$val = preg_replace('/\s*[-]\s*\d{1,4}\s*$/', '', $val);
// Extract only digits
$digits = preg_replace('/[^\d]/', '', $val);
if ($digits === '') {
return '';
}
// 8-digit Bahrain local number → prepend 973
if (strlen($digits) === 8) {
return '+973' . $digits;
}
// Already has country code as prefix
return '+' . $digits;
}
private function parseCreditDays(string $val): ?int
{
$digits = preg_replace('/[^\d]/', '', $val);
return $digits !== '' ? (int) $digits : null;
}
private function parseBoolean(string $value): bool
{
return in_array(strtolower(trim($value)), ['yes', 'true', '1', 'active', 'y']);
}
private function str(string $val): ?string
{
$v = trim($val);
return $v !== '' ? $v : null;
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.app');
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class GuestLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.guest');
}
}

18
artisan Normal file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

22
bootstrap/app.php Normal file
View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

7
bootstrap/providers.php Normal file
View File

@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];

90
composer.json Normal file
View File

@ -0,0 +1,90 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"phpoffice/phpspreadsheet": "^5.7",
"spatie/laravel-permission": "^6.25"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/breeze": "^2.4",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.50"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

9447
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View File

@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

Some files were not shown because too many files have changed in this diff Show More