Compare commits

..
23 Commits
Author SHA1 Message Date
ghassanandClaude Sonnet 4.6 9c4c752800 feat: quote detail modal, SteelERP favicon, and notification navigation
- Pipeline page: clicking a supplier quote card opens a modal with full
  line-item breakdown, lead time, payment terms, notes, awarded status,
  and a Compare All Quotes button
- Eager-load supplierQuotes.supplier and supplierQuotes.items in pipeline
  controller to avoid N+1 on the modal data
- Browser tab now shows SteelERP first (SteelERP — Page Name)
- Added SVG favicon matching the sidebar blue-square logo
- Notification clicks now navigate to the relevant page via a dedicated
  /notifications/{id}/go route that marks only that notification as read

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-01 17:39:04 +03:00
ghassan 30b2fb3958 fix: move local packages inside repo so remote server can install them 2026-06-01 13:48:08 +03:00
ghassan 50e5f3e381 fix: move local packages inside repo so remote server can install them 2026-06-01 13:47:29 +03:00
ghassan d28e7330e6 fix: wider RFQ table, more padding, subtle blue VAT row 2026-06-01 12:37:34 +03:00
ghassan 716f0afcac fix: VAT row same color as other footer rows 2026-06-01 12:24:18 +03:00
ghassan 6061e8ca4f feat: supplier item name editing, N/A checkbox, and adjusted indicator on compare view 2026-06-01 12:19:52 +03:00
ghassan 7b399d5167 feat: add VAT checkbox column and live breakdown to RFQ portal 2026-06-01 12:11:54 +03:00
ghassan ee89269c0b feat: load VAT rate and store is_vatable in RFQ portal 2026-06-01 12:11:02 +03:00
ghassan a9db089a90 feat: add VAT Settings link to sidebar 2026-06-01 12:10:27 +03:00
ghassan 04ced4d2da feat: add VAT settings page and controller 2026-06-01 12:10:01 +03:00
ghassan b19396c996 feat: add is_vatable column to supplier_quote_items 2026-06-01 12:09:13 +03:00
ghassan 3252cf7fb8 docs: add VAT + RFQ vatable checkbox design spec 2026-06-01 12:06:37 +03:00
ghassanandClaude Sonnet 4.6 dca9cd5d99 feat: RFQ portal, notifications, and project settings updates
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-01 11:52:21 +03:00
ghassanandClaude Sonnet 4.6 72e6c3170e fix: use label[for] to trigger file input in import modal
Browsers treat label clicks as direct user gestures, ensuring the
OS file picker always opens. Removes unreliable div.onclick -> input.click()
pattern. Also fixes DataTransfer.files assignment (read-only in some browsers).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 18:51:26 +03:00
ghassanandClaude Sonnet 4.6 f2c0f22156 feat: add Excel importer and template download for projects/departments
- ProjectImportService: reads Projects + Departments sheets, creates
  companies automatically if missing, skips duplicates (case-insensitive)
- Controller: import() POST (JSON response) + downloadTemplate() GET
- Template: 2-tab .xlsx (Projects | Departments) with headers, sample rows, note
- View: Import Excel button (AJAX upload with drag-and-drop modal) +
  Template download button in page header; page reloads after successful import

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 18:42:25 +03:00
ghassanandClaude Sonnet 4.6 12e07480a0 feat: move departments from projects to companies
Departments now belong to a Company, not a Project.
- New migration: recreates settings_departments with company_id (migrates existing data)
- Department model: company_id FK, company() relation
- Company model: departments() hasMany relation
- ProjectSetting model: removes departments() relation
- Controller: dept methods now take Company instead of ProjectSetting
- Routes: department CRUD moves to companies/{company}/departments
- View: departments section appears on each company card (purple theme);
  project body is now locations-only (single column)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 18:26:27 +03:00
ghassanandClaude Sonnet 4.6 9a268dc79b feat: add company reassignment to project edit strip
Each project's Edit strip now has a Company dropdown.
Changing company and saving moves the project card to the
correct company section live without a page reload.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 18:17:50 +03:00
ghassanandClaude Sonnet 4.6 a425e12349 feat: add companies as top-level container for projects with departments
- Company → Project → (Locations + Departments) hierarchy
- Company CRUD (add, edit, delete) with AJAX
- Projects are created under a company via inline strip
- Two-column project body: Locations | Departments
- Stats show companies, projects, locations, departments, active projects
- Dynamic stat counters update on add/delete without page reload

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 18:05:04 +03:00
ghassan 60d57af630 feat: add departments to projects — two-column layout with inline CRUD 2026-05-26 17:41:15 +03:00
ghassan 58b3e9e0de feat: remove accordion from projects — all projects always expanded 2026-05-26 17:33:39 +03:00
ghassan b1986e8654 all 2026-05-26 17:17:05 +03:00
ghassan a43805e30c debug: log full trace on sendTestEmail failure 2026-05-26 16:41:06 +03:00
ghassan 6955d7b422 fix: pass transport directly to Mailer constructor, not wrapped in Symfony Mailer 2026-05-26 16:32:28 +03:00
95 changed files with 7150 additions and 409 deletions

No files matched your search

+2
View File
@@ -19,6 +19,8 @@
/storage/*.key
/storage/pail
/vendor
/packages/azure-mailer/vendor
/packages/ultra-message/vendor
Homestead.json
Homestead.yaml
Thumbs.db
@@ -0,0 +1,139 @@
<h2>Full Form Mockup — New Purchase Request</h2>
<p class="subtitle">Showing the improved header section with all dropdowns and the urgency popup. Click "Urgency" to see the popup demo.</p>
<div class="mockup" style="max-width:900px;margin:0 auto;">
<div class="mockup-header">Purchase Request Form — Header Section</div>
<div class="mockup-body" style="background:#f8fafc;padding:20px;">
<!-- Form card -->
<div style="background:#fff;border-radius:10px;border:1px solid #e2e8f0;padding:20px;margin-bottom:16px;">
<div style="font-size:13px;font-weight:600;color:#374151;margin-bottom:14px;">Project / Department Details</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
<!-- Date -->
<div>
<div style="font-size:11px;font-weight:600;color:#6b7280;margin-bottom:4px;">Date <span style="color:#ef4444">*</span></div>
<div style="border:1px solid #d1d5db;border-radius:6px;padding:7px 10px;font-size:12px;color:#374151;background:#fff;">2026-05-24</div>
</div>
<!-- Project Name — dropdown -->
<div>
<div style="font-size:11px;font-weight:600;color:#6b7280;margin-bottom:4px;">Project / Site Name <span style="color:#ef4444">*</span></div>
<div style="border:1px solid #d1d5db;border-radius:6px;padding:7px 10px;font-size:12px;color:#374151;background:#fff;display:flex;justify-content:space-between;align-items:center;cursor:pointer;">
<span style="color:#9ca3af;">Select project…</span>
<span style="color:#9ca3af;font-size:10px;">▼</span>
</div>
</div>
<!-- Requested By -->
<div>
<div style="font-size:11px;font-weight:600;color:#6b7280;margin-bottom:4px;">Requested By <span style="color:#ef4444">*</span></div>
<div style="border:1px solid #d1d5db;border-radius:6px;padding:7px 10px;font-size:12px;color:#374151;background:#fff;">Ahmed Al-Mansoori</div>
</div>
<!-- Required Date / Urgency — popup trigger -->
<div style="position:relative;">
<div style="font-size:11px;font-weight:600;color:#6b7280;margin-bottom:4px;">Required Date / Urgency</div>
<div id="urgency-trigger" onclick="toggleUrgencyPopup()" style="border:1px solid #d1d5db;border-radius:6px;padding:7px 10px;font-size:12px;color:#9ca3af;background:#fff;display:flex;justify-content:space-between;align-items:center;cursor:pointer;">
<span id="urgency-display">Select urgency…</span>
<span style="font-size:14px;">🎯</span>
</div>
<!-- Urgency popup -->
<div id="urgency-popup" style="display:none;position:absolute;top:100%;left:0;z-index:50;margin-top:4px;background:#fff;border-radius:12px;border:1.5px solid #e2e8f0;box-shadow:0 8px 24px rgba(0,0,0,0.12);padding:14px;width:280px;">
<div style="font-size:11px;font-weight:600;color:#94a3b8;margin-bottom:10px;text-transform:uppercase;letter-spacing:0.05em;">How soon is this needed?</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;" id="urgency-cards">
<div onclick="pickUrgency('Critical','🚨','#fee2e2','#dc2626')" style="border:1.5px solid #e2e8f0;border-radius:10px;padding:10px;text-align:center;cursor:pointer;transition:all 0.15s;" class="urg-card">
<div style="font-size:20px;margin-bottom:2px;">🚨</div>
<div style="font-size:11px;font-weight:700;color:#dc2626;">Critical</div>
<div style="font-size:10px;color:#94a3b8;">Today</div>
</div>
<div onclick="pickUrgency('Urgent','⚡','#ffedd5','#ea580c')" style="border:1.5px solid #e2e8f0;border-radius:10px;padding:10px;text-align:center;cursor:pointer;" class="urg-card">
<div style="font-size:20px;margin-bottom:2px;">⚡</div>
<div style="font-size:11px;font-weight:700;color:#ea580c;">Urgent</div>
<div style="font-size:10px;color:#94a3b8;">1–3 days</div>
</div>
<div onclick="pickUrgency('Normal','📋','#fef9c3','#ca8a04')" style="border:1.5px solid #e2e8f0;border-radius:10px;padding:10px;text-align:center;cursor:pointer;" class="urg-card">
<div style="font-size:20px;margin-bottom:2px;">📋</div>
<div style="font-size:11px;font-weight:700;color:#ca8a04;">Normal</div>
<div style="font-size:10px;color:#94a3b8;">This week</div>
</div>
<div onclick="showDatePicker()" style="border:1.5px solid #e2e8f0;border-radius:10px;padding:10px;text-align:center;cursor:pointer;" class="urg-card" id="planned-card">
<div style="font-size:20px;margin-bottom:2px;">🗓️</div>
<div style="font-size:11px;font-weight:700;color:#16a34a;">Planned</div>
<div style="font-size:10px;color:#94a3b8;">Pick date</div>
</div>
</div>
<!-- date picker section, hidden until Planned clicked -->
<div id="date-section" style="display:none;margin-top:10px;border-top:1px solid #f1f5f9;padding-top:10px;">
<div style="font-size:11px;color:#6b7280;margin-bottom:4px;">Specify the required date:</div>
<input type="date" id="planned-date" style="width:100%;border:1.5px solid #3b82f6;border-radius:6px;padding:6px 10px;font-size:12px;" onchange="confirmDate(this.value)">
</div>
</div>
</div>
<!-- Location / Site — dropdown -->
<div>
<div style="font-size:11px;font-weight:600;color:#6b7280;margin-bottom:4px;">Location / Site</div>
<div style="border:1px solid #d1d5db;border-radius:6px;padding:7px 10px;font-size:12px;color:#374151;background:#fff;display:flex;justify-content:space-between;align-items:center;cursor:pointer;">
<span style="color:#9ca3af;">Select location…</span>
<span style="color:#9ca3af;font-size:10px;">▼</span>
</div>
</div>
<!-- Department -->
<div>
<div style="font-size:11px;font-weight:600;color:#6b7280;margin-bottom:4px;">Department</div>
<div style="border:1px solid #d1d5db;border-radius:6px;padding:7px 10px;font-size:12px;color:#9ca3af;background:#fff;">e.g. Operations, Production</div>
</div>
</div>
</div>
<!-- Settings sidebar hint -->
<div style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:12px;font-size:11px;color:#1d4ed8;">
⚙️ <strong>New Settings section in sidebar</strong> — Admins can manage <em>Locations</em>, <em>Projects</em>, and <em>Urgency Levels</em> from here. Each has a simple list with Add / Edit / Delete.
</div>
</div>
</div>
<script>
function toggleUrgencyPopup() {
var p = document.getElementById('urgency-popup');
p.style.display = p.style.display === 'none' ? 'block' : 'none';
}
function pickUrgency(label, emoji, bg, color) {
document.getElementById('urgency-display').innerHTML = '<span style="background:' + bg + ';color:' + color + ';border-radius:20px;padding:2px 10px;font-weight:600;">' + emoji + ' ' + label + '</span>';
document.getElementById('urgency-popup').style.display = 'none';
document.getElementById('date-section').style.display = 'none';
// reset planned card
document.getElementById('planned-card').style.borderColor = '#e2e8f0';
document.getElementById('planned-card').style.background = '#fff';
}
function showDatePicker() {
var section = document.getElementById('date-section');
section.style.display = section.style.display === 'none' ? 'block' : 'none';
document.getElementById('planned-card').style.borderColor = '#22c55e';
document.getElementById('planned-card').style.background = '#f0fdf4';
}
function confirmDate(val) {
if (!val) return;
var d = new Date(val);
var formatted = d.toLocaleDateString('en-GB', {day:'2-digit',month:'short',year:'numeric'});
document.getElementById('urgency-display').innerHTML = '<span style="background:#f0fdf4;color:#16a34a;border-radius:20px;padding:2px 10px;font-weight:600;">🗓️ ' + formatted + '</span>';
setTimeout(function(){ document.getElementById('urgency-popup').style.display = 'none'; }, 300);
}
// close on outside click
document.addEventListener('click', function(e) {
var popup = document.getElementById('urgency-popup');
var trigger = document.getElementById('urgency-trigger');
if (popup && !popup.contains(e.target) && !trigger.contains(e.target)) {
popup.style.display = 'none';
}
});
</script>
@@ -0,0 +1,87 @@
<h2>Required Date / Urgency — which style feels right?</h2>
<p class="subtitle">When the user clicks the field, a popup opens. Pick the style you prefer.</p>
<div class="cards" style="grid-template-columns: repeat(3, 1fr); gap: 20px;">
<!-- Option A -->
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image" style="padding:16px; background:#f8fafc;">
<div style="font-size:11px;color:#64748b;font-weight:600;text-transform:uppercase;margin-bottom:8px;">Urgency</div>
<div style="border:1.5px solid #3b82f6;border-radius:8px;padding:10px;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="font-size:11px;color:#94a3b8;margin-bottom:8px;">Select urgency level</div>
<div style="display:flex;flex-direction:column;gap:6px;">
<div style="background:#fee2e2;color:#dc2626;border-radius:6px;padding:7px 12px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px;">🔴 Critical / Immediate</div>
<div style="background:#ffedd5;color:#ea580c;border-radius:6px;padding:7px 12px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px;">🟠 Urgent — Within 3 days</div>
<div style="background:#fef9c3;color:#ca8a04;border-radius:6px;padding:7px 12px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px;">🟡 This Week</div>
<div style="background:#dcfce7;color:#16a34a;border-radius:6px;padding:7px 12px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px;">🟢 Normal — This Month</div>
<div style="border:1px solid #e2e8f0;border-radius:6px;padding:7px 12px;font-size:12px;font-weight:600;color:#475569;display:flex;align-items:center;gap:6px;">📅 Pick a specific date →</div>
</div>
</div>
</div>
<div class="card-body">
<h3>A — Urgency Levels</h3>
<p>Colored pills with preset labels. Optionally jump to a date picker.</p>
</div>
</div>
<!-- Option B -->
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image" style="padding:16px; background:#f8fafc;">
<div style="font-size:11px;color:#64748b;font-weight:600;text-transform:uppercase;margin-bottom:8px;">Urgency</div>
<div style="border:1.5px solid #3b82f6;border-radius:8px;padding:12px;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="font-size:11px;color:#94a3b8;margin-bottom:10px;">How soon is this needed?</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:10px;">
<div style="border:1.5px solid #dc2626;border-radius:8px;padding:8px;text-align:center;background:#fff5f5;">
<div style="font-size:18px;">🚨</div>
<div style="font-size:11px;font-weight:700;color:#dc2626;">Critical</div>
<div style="font-size:10px;color:#94a3b8;">Today</div>
</div>
<div style="border:1.5px solid #f97316;border-radius:8px;padding:8px;text-align:center;background:#fff7ed;">
<div style="font-size:18px;">⚡</div>
<div style="font-size:11px;font-weight:700;color:#ea580c;">Urgent</div>
<div style="font-size:10px;color:#94a3b8;">1–3 days</div>
</div>
<div style="border:1.5px solid #eab308;border-radius:8px;padding:8px;text-align:center;background:#fefce8;">
<div style="font-size:18px;">📋</div>
<div style="font-size:11px;font-weight:700;color:#ca8a04;">Normal</div>
<div style="font-size:10px;color:#94a3b8;">This week</div>
</div>
<div style="border:1.5px solid #22c55e;border-radius:8px;padding:8px;text-align:center;background:#f0fdf4;">
<div style="font-size:18px;">🗓️</div>
<div style="font-size:11px;font-weight:700;color:#16a34a;">Planned</div>
<div style="font-size:10px;color:#94a3b8;">Pick date</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>B — Icon Cards</h3>
<p>2×2 grid of icon cards with urgency label + subtitle. Visually distinct.</p>
</div>
</div>
<!-- Option C -->
<div class="card" data-choice="c" onclick="toggleSelect(this)">
<div class="card-image" style="padding:16px; background:#f8fafc;">
<div style="font-size:11px;color:#64748b;font-weight:600;text-transform:uppercase;margin-bottom:8px;">Urgency</div>
<div style="border:1.5px solid #3b82f6;border-radius:8px;padding:12px;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="font-size:11px;color:#94a3b8;margin-bottom:10px;">Quick select or pick a date</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px;">
<span style="background:#fee2e2;color:#dc2626;border-radius:20px;padding:5px 12px;font-size:11px;font-weight:600;cursor:pointer;">🔴 Critical</span>
<span style="background:#ffedd5;color:#ea580c;border-radius:20px;padding:5px 12px;font-size:11px;font-weight:600;cursor:pointer;">⚡ Urgent</span>
<span style="background:#fef9c3;color:#ca8a04;border-radius:20px;padding:5px 12px;font-size:11px;font-weight:600;cursor:pointer;">📋 This Week</span>
<span style="background:#dcfce7;color:#16a34a;border-radius:20px;padding:5px 12px;font-size:11px;font-weight:600;cursor:pointer;">✅ Normal</span>
</div>
<div style="border-top:1px solid #f1f5f9;padding-top:8px;">
<div style="font-size:10px;color:#94a3b8;margin-bottom:4px;">Or pick a specific date:</div>
<div style="border:1px solid #e2e8f0;border-radius:6px;padding:5px 10px;font-size:11px;color:#94a3b8;background:#f8fafc;">📅 Select date…</div>
</div>
</div>
</div>
<div class="card-body">
<h3>C — Chips + Date</h3>
<p>Pill-shaped tags for quick picks, plus a date field below for exact dates.</p>
</div>
</div>
</div>
@@ -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>
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1779606561971}
@@ -0,0 +1 @@
1730
@@ -0,0 +1,78 @@
<h2>Supplier Modal — 3 Redesign Approaches</h2>
<p class="subtitle">The core problem: the two tabs (Full Order / By Item) are mutually exclusive but look like equals. Choosing one should lock out the other. Here are three ways to fix that.</p>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Two-Step Wizard <span style="font-size:11px;background:#dbeafe;color:#1d4ed8;padding:2px 8px;border-radius:20px;font-weight:700;margin-left:6px;">Recommended</span></h3>
<p>The modal opens on a "choose your method" screen with two large cards. Clicking one card transitions into that mode — no tabs ever appear. A small "← Change method" back link lets you restart if needed.</p>
<div style="margin-top:10px;background:#f8fafc;border-radius:10px;padding:12px;font-size:12px;">
<div style="display:flex;gap:8px;margin-bottom:6px;">
<div style="background:#eff6ff;border:2px solid #2563eb;border-radius:8px;padding:10px 16px;flex:1;text-align:center;">
<div style="font-size:18px;margin-bottom:4px;">📦</div>
<div style="font-weight:700;color:#0f172a;font-size:12px;">Full Order</div>
<div style="color:#64748b;font-size:10px;margin-top:2px;">One set of suppliers for everything</div>
</div>
<div style="background:#fff;border:1.5px solid #e2e8f0;border-radius:8px;padding:10px 16px;flex:1;text-align:center;">
<div style="font-size:18px;margin-bottom:4px;">🔀</div>
<div style="font-weight:700;color:#0f172a;font-size:12px;">By Item</div>
<div style="color:#64748b;font-size:10px;margin-top:2px;">Different suppliers per item</div>
</div>
</div>
<div style="color:#94a3b8;text-align:center;font-size:10px;">↓ clicking a card opens that flow directly</div>
</div>
<div class="pros-cons" style="margin-top:10px;">
<div class="pros"><h4>Pros</h4><ul><li>Zero ambiguity — only one path at a time</li><li>Clean separation of concerns</li><li>Escape hatch via back link</li></ul></div>
<div class="cons"><h4>Cons</h4><ul><li>Extra click before seeing suppliers</li></ul></div>
</div>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>Wizard with Step Indicator</h3>
<p>Same two-step wizard as A, but with a visible step breadcrumb at the top: <strong>① Choose Method → ② Select Suppliers</strong>. Adds visual progress context for users who need orientation.</p>
<div style="margin-top:10px;background:#f8fafc;border-radius:10px;padding:12px;font-size:12px;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:10px;">
<div style="background:#2563eb;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;">1</div>
<div style="font-size:11px;font-weight:700;color:#2563eb;">Choose Method</div>
<div style="flex:1;height:1px;background:#e2e8f0;"></div>
<div style="background:#e2e8f0;color:#94a3b8;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;">2</div>
<div style="font-size:11px;color:#94a3b8;">Select Suppliers</div>
</div>
<div style="color:#94a3b8;text-align:center;font-size:10px;">step indicator lives in the modal header</div>
</div>
<div class="pros-cons" style="margin-top:10px;">
<div class="pros"><h4>Pros</h4><ul><li>Users see where they are in the flow</li><li>Familiar wizard UX pattern</li></ul></div>
<div class="cons"><h4>Cons</h4><ul><li>Slightly more UI chrome</li><li>Overkill for a 2-step flow</li></ul></div>
</div>
</div>
</div>
<div class="option" data-choice="c" onclick="toggleSelect(this)">
<div class="letter">C</div>
<div class="content">
<h3>Prominent Mode Toggle (No Wizard)</h3>
<p>Keep a single-screen modal but replace the tabs with a large segmented toggle at the top. Switching mode clears all selections and shows a confirmation toast. Less structural change, but still communicates mutual exclusivity.</p>
<div style="margin-top:10px;background:#f8fafc;border-radius:10px;padding:12px;font-size:12px;">
<div style="display:flex;border:2px solid #e2e8f0;border-radius:10px;overflow:hidden;margin-bottom:6px;">
<div style="flex:1;padding:8px 12px;background:#2563eb;color:#fff;text-align:center;font-weight:700;font-size:12px;">
📦 Full Order
</div>
<div style="flex:1;padding:8px 12px;background:#fff;color:#94a3b8;text-align:center;font-weight:600;font-size:12px;">
🔀 By Item
</div>
</div>
<div style="color:#f59e0b;font-size:10px;text-align:center;">⚠ Switching clears your current selections</div>
</div>
<div class="pros-cons" style="margin-top:10px;">
<div class="pros"><h4>Pros</h4><ul><li>Fewest changes to existing code</li><li>Both options always visible</li></ul></div>
<div class="cons"><h4>Cons</h4><ul><li>Still looks like "tabs" — same confusion risk</li><li>Warning toast feels like an afterthought</li></ul></div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,36 @@
<h2>Back Button Behavior</h2>
<p class="subtitle">When the user clicks "← Change method" from step 2, what happens to their selections?</p>
<div class="options">
<div class="option" data-choice="clear" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Clear selections on back <span style="font-size:11px;background:#dbeafe;color:#1d4ed8;padding:2px 8px;border-radius:20px;font-weight:700;margin-left:6px;">Recommended</span></h3>
<p>Going back always resets everything. Switching from Full Order → By Item (or vice versa) is a fundamentally different configuration, so a clean slate avoids stale state.</p>
<div style="margin-top:10px;background:#f8fafc;border-radius:10px;padding:12px;font-size:11px;color:#475569;">
<strong>Flow:</strong> Pick method → select suppliers → back → <em>selections cleared</em> → pick method again → fresh start
</div>
<div class="pros-cons" style="margin-top:10px;">
<div class="pros"><h4>Pros</h4><ul><li>Simple, no hidden state</li><li>No risk of mixing Full Order + By Item data</li></ul></div>
<div class="cons"><h4>Cons</h4><ul><li>User loses work if they go back accidentally</li></ul></div>
</div>
</div>
</div>
<div class="option" data-choice="preserve" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>Preserve selections on back</h3>
<p>Selections are remembered per-mode. If the user goes back and re-enters the same mode, their previous picks are still there. Switching to the <em>other</em> mode starts fresh for that mode only.</p>
<div style="margin-top:10px;background:#f8fafc;border-radius:10px;padding:12px;font-size:11px;color:#475569;">
<strong>Flow:</strong> Full Order → select 3 suppliers → back → pick Full Order again → 3 suppliers still checked
</div>
<div class="pros-cons" style="margin-top:10px;">
<div class="pros"><h4>Pros</h4><ul><li>Forgiving — accidental back doesn't lose work</li></ul></div>
<div class="cons"><h4>Cons</h4><ul><li>More complex state management</li><li>Can confuse users who expect a fresh start</li></ul></div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,125 @@
<h2>Wizard Flow Mockup</h2>
<p class="subtitle">Step 1 (method selection) and what Step 2 looks like after choosing. Does this feel right?</p>
<div class="split">
<div class="mockup">
<div class="mockup-header">Step 1 — Choose Method (modal opens here)</div>
<div class="mockup-body" style="padding:0;">
<!-- Modal shell -->
<div style="background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,.12);">
<!-- Header -->
<div style="padding:20px 24px 20px;border-bottom:1px solid #f1f5f9;display:flex;align-items:flex-start;justify-content:space-between;">
<div>
<div style="font-size:16px;font-weight:700;color:#0f172a;">Request for Quotation</div>
<div style="font-size:11px;color:#64748b;margin-top:3px;">How do you want to assign suppliers?</div>
</div>
<div style="width:28px;height:28px;background:#f1f5f9;border-radius:7px;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:16px;cursor:pointer;">×</div>
</div>
<!-- Method cards -->
<div style="padding:24px;display:flex;flex-direction:column;gap:12px;">
<div style="border:2px solid #e2e8f0;border-radius:12px;padding:18px 20px;cursor:pointer;display:flex;align-items:center;gap:16px;transition:all .15s;"
onmouseover="this.style.borderColor='#2563eb';this.style.background='#f8fbff'"
onmouseout="this.style.borderColor='#e2e8f0';this.style.background='#fff'">
<div style="width:44px;height:44px;background:#eff6ff;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;">📦</div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:700;color:#0f172a;">Full Order</div>
<div style="font-size:12px;color:#64748b;margin-top:2px;">One set of suppliers handles the entire purchase request</div>
</div>
<svg width="16" height="16" fill="none" stroke="#cbd5e1" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18l6-6-6-6"/></svg>
</div>
<div style="border:2px solid #e2e8f0;border-radius:12px;padding:18px 20px;cursor:pointer;display:flex;align-items:center;gap:16px;"
onmouseover="this.style.borderColor='#2563eb';this.style.background='#f8fbff'"
onmouseout="this.style.borderColor='#e2e8f0';this.style.background='#fff'">
<div style="width:44px;height:44px;background:#f0fdf4;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;">🔀</div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:700;color:#0f172a;">By Item</div>
<div style="font-size:12px;color:#64748b;margin-top:2px;">Assign different suppliers to specific items in this request</div>
</div>
<svg width="16" height="16" fill="none" stroke="#cbd5e1" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18l6-6-6-6"/></svg>
</div>
</div>
<!-- Footer -->
<div style="padding:14px 24px;border-top:1px solid #f1f5f9;background:#fafafa;display:flex;justify-content:flex-end;">
<button style="padding:8px 18px;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;font-weight:600;color:#64748b;background:#fff;cursor:pointer;">Cancel</button>
</div>
</div>
</div>
</div>
<div class="mockup">
<div class="mockup-header">Step 2 — After choosing "Full Order"</div>
<div class="mockup-body" style="padding:0;">
<div style="background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,.12);">
<!-- Header with back link -->
<div style="padding:16px 24px 16px;border-bottom:1px solid #f1f5f9;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:4px;">
<button style="display:flex;align-items:center;gap:5px;font-size:12px;color:#2563eb;background:none;border:none;cursor:pointer;padding:0;font-weight:600;">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 18l-6-6 6-6"/></svg>
Change method
</button>
<div style="font-size:11px;color:#94a3b8;">·</div>
<div style="font-size:11px;background:#eff6ff;color:#2563eb;padding:2px 8px;border-radius:10px;font-weight:700;">📦 Full Order</div>
</div>
<div style="font-size:16px;font-weight:700;color:#0f172a;">Select Suppliers</div>
<div style="font-size:11px;color:#64748b;margin-top:2px;">Choose who receives the quote request</div>
</div>
<!-- Search -->
<div style="padding:12px 24px 8px;">
<div style="position:relative;">
<div style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:#94a3b8;font-size:12px;">🔍</div>
<div style="width:100%;padding:8px 12px 8px 30px;border:1px solid #e2e8f0;border-radius:8px;font-size:12px;color:#94a3b8;box-sizing:border-box;">Search suppliers…</div>
</div>
</div>
<!-- Supplier rows -->
<div style="max-height:160px;overflow:hidden;">
<div style="padding:10px 24px;display:flex;align-items:center;gap:12px;border-bottom:1px solid #f8fafc;">
<div style="width:16px;height:16px;border:2px solid #2563eb;border-radius:3px;background:#2563eb;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<svg width="9" height="9" fill="none" stroke="#fff" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</div>
<div style="flex:1;">
<div style="font-size:12px;font-weight:600;color:#0f172a;">Al-Rashid Steel Trading</div>
<div style="font-size:10px;color:#94a3b8;">[email protected] · +966 50 123 4567</div>
</div>
<div style="display:flex;border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;font-size:10px;">
<span style="padding:4px 8px;background:#eff6ff;color:#2563eb;font-weight:700;">Email</span>
<span style="padding:4px 8px;color:#94a3b8;border-left:1px solid #e2e8f0;">WA</span>
<span style="padding:4px 8px;color:#94a3b8;border-left:1px solid #e2e8f0;">Both</span>
</div>
</div>
<div style="padding:10px 24px;display:flex;align-items:center;gap:12px;border-bottom:1px solid #f8fafc;">
<div style="width:16px;height:16px;border:2px solid #cbd5e1;border-radius:3px;flex-shrink:0;"></div>
<div style="flex:1;">
<div style="font-size:12px;font-weight:600;color:#0f172a;">Gulf Metals Co.</div>
<div style="font-size:10px;color:#94a3b8;">[email protected]</div>
</div>
</div>
<div style="padding:10px 24px;display:flex;align-items:center;gap:12px;border-bottom:1px solid #f8fafc;opacity:.45;pointer-events:none;">
<div style="width:16px;height:16px;border:2px solid #2563eb;border-radius:3px;background:#2563eb;flex-shrink:0;"></div>
<div style="flex:1;">
<div style="font-size:12px;font-weight:600;color:#0f172a;">Emirates Industrial Supply <span style="font-size:9px;background:#dcfce7;color:#15803d;padding:1px 5px;border-radius:8px;margin-left:4px;font-weight:700;">Added</span></div>
<div style="font-size:10px;color:#94a3b8;">+971 4 987 6543</div>
</div>
</div>
</div>
<!-- Footer -->
<div style="padding:12px 24px;border-top:1px solid #f1f5f9;background:#fafafa;display:flex;align-items:center;justify-content:space-between;">
<div style="font-size:11px;color:#64748b;">1 supplier selected</div>
<div style="display:flex;gap:8px;">
<button style="padding:7px 16px;border:1px solid #e2e8f0;border-radius:8px;font-size:12px;font-weight:600;color:#64748b;background:#fff;cursor:pointer;">Cancel</button>
<button style="padding:7px 18px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;">Save &amp; Continue →</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1779709143287}
@@ -0,0 +1 @@
2032
@@ -0,0 +1,168 @@
<h2>Add Account Modal — Both Type Variants</h2>
<p class="subtitle">Left: Microsoft 365 (Azure AD) fields. Right: SMTP fields. The type dropdown switches which fields appear. Account name is a slug used in code.</p>
<div class="split">
<!-- Azure variant -->
<div class="mockup">
<div class="mockup-header">Add Mail Account — Microsoft 365</div>
<div class="mockup-body" style="padding:24px;background:#fff;">
<div style="margin-bottom:16px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Account Name <span style="color:#9ca3af;font-weight:400;">(used in code)</span></div>
<div style="height:38px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:13px;color:#374151;">customer-support</span>
</div>
<div style="font-size:11px;color:#9ca3af;margin-top:3px;">Lowercase, hyphens only. Used as <code style="background:#f3f4f6;padding:1px 5px;border-radius:3px;">Mail::mailer('customer-support')</code></div>
</div>
<div style="margin-bottom:16px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Type</div>
<div style="height:38px;background:#fff;border:1px solid #2563eb;border-radius:7px;padding:0 12px;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:13px;color:#374151;">✉️ Microsoft 365 (Azure AD)</span>
<span style="font-size:11px;color:#9ca3af;">▼</span>
</div>
</div>
<div style="border-top:1px solid #f3f4f6;margin:0 0 16px;padding-top:16px;">
<div style="font-size:11px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:.05em;margin-bottom:12px;">Azure AD Credentials</div>
<div style="margin-bottom:12px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Tenant ID</div>
<div style="height:36px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:12px;color:#9ca3af;">xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</span>
</div>
</div>
<div style="margin-bottom:12px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Client ID</div>
<div style="height:36px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:12px;color:#9ca3af;">xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</span>
</div>
</div>
<div style="margin-bottom:12px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Client Secret</div>
<div style="height:36px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:12px;color:#9ca3af;letter-spacing:2px;">••••••••••••</span>
<span style="font-size:11px;color:#6b7280;background:#f9fafb;padding:2px 7px;border-radius:4px;border:1px solid #e5e7eb;">Show</span>
</div>
</div>
</div>
<div style="border-top:1px solid #f3f4f6;padding-top:16px;margin-bottom:16px;">
<div style="font-size:11px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:.05em;margin-bottom:12px;">Sender</div>
<div style="margin-bottom:12px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">From Address</div>
<div style="height:36px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:12px;color:#9ca3af;">[email protected]</span>
</div>
</div>
<div>
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">From Name <span style="color:#9ca3af;font-weight:400;">(optional)</span></div>
<div style="height:36px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:12px;color:#9ca3af;">SteelERP</span>
</div>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #f3f4f6;padding-top:16px;">
<span style="font-size:13px;color:#2563eb;text-decoration:underline;cursor:pointer;">🔗 Test Connection</span>
<div style="display:flex;gap:8px;">
<div style="padding:8px 16px;background:#f9fafb;color:#374151;border:1px solid #e5e7eb;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;">Cancel</div>
<div style="padding:8px 20px;background:#111827;color:#fff;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;">Save Account</div>
</div>
</div>
</div>
</div>
<!-- SMTP variant -->
<div class="mockup">
<div class="mockup-header">Add Mail Account — SMTP</div>
<div class="mockup-body" style="padding:24px;background:#fff;">
<div style="margin-bottom:16px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Account Name <span style="color:#9ca3af;font-weight:400;">(used in code)</span></div>
<div style="height:38px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:13px;color:#374151;">invoices</span>
</div>
<div style="font-size:11px;color:#9ca3af;margin-top:3px;">Lowercase, hyphens only. Used as <code style="background:#f3f4f6;padding:1px 5px;border-radius:3px;">Mail::mailer('invoices')</code></div>
</div>
<div style="margin-bottom:16px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Type</div>
<div style="height:38px;background:#fff;border:1px solid #2563eb;border-radius:7px;padding:0 12px;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:13px;color:#374151;">📧 SMTP</span>
<span style="font-size:11px;color:#9ca3af;">▼</span>
</div>
</div>
<div style="border-top:1px solid #f3f4f6;margin:0 0 16px;padding-top:16px;">
<div style="font-size:11px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:.05em;margin-bottom:12px;">SMTP Server</div>
<div style="display:flex;gap:10px;margin-bottom:12px;">
<div style="flex:1;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Host</div>
<div style="height:36px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:12px;color:#9ca3af;">smtp.gmail.com</span>
</div>
</div>
<div style="width:80px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Port</div>
<div style="height:36px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:12px;color:#374151;">587</span>
</div>
</div>
<div style="width:90px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Encryption</div>
<div style="height:36px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:12px;color:#374151;">TLS</span>
<span style="font-size:10px;color:#9ca3af;">▼</span>
</div>
</div>
</div>
<div style="margin-bottom:12px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Username</div>
<div style="height:36px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:12px;color:#9ca3af;">[email protected]</span>
</div>
</div>
<div style="margin-bottom:12px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Password</div>
<div style="height:36px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:12px;color:#9ca3af;letter-spacing:2px;">••••••••••••</span>
<span style="font-size:11px;color:#6b7280;background:#f9fafb;padding:2px 7px;border-radius:4px;border:1px solid #e5e7eb;">Show</span>
</div>
</div>
</div>
<div style="border-top:1px solid #f3f4f6;padding-top:16px;margin-bottom:16px;">
<div style="font-size:11px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:.05em;margin-bottom:12px;">Sender</div>
<div style="margin-bottom:12px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">From Address</div>
<div style="height:36px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:12px;color:#9ca3af;">[email protected]</span>
</div>
</div>
<div>
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">From Name <span style="color:#9ca3af;font-weight:400;">(optional)</span></div>
<div style="height:36px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:12px;color:#9ca3af;">SteelERP Invoices</span>
</div>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #f3f4f6;padding-top:16px;">
<span style="font-size:13px;color:#2563eb;text-decoration:underline;cursor:pointer;">🔗 Test Connection</span>
<div style="display:flex;gap:8px;">
<div style="padding:8px 16px;background:#f9fafb;color:#374151;border:1px solid #e5e7eb;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;">Cancel</div>
<div style="padding:8px 20px;background:#111827;color:#fff;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;">Save Account</div>
</div>
</div>
</div>
</div>
</div>
<p class="subtitle" style="margin-top:16px;">Both variants use the same modal shell — only the credential section changes based on the Type dropdown. Does this look right? Let me know in the terminal.</p>
@@ -0,0 +1,133 @@
<h2>Email Tab — Full Design</h2>
<p class="subtitle">Here's what the Email (Microsoft 365) tab looks like with all its fields. Does this match what you had in mind?</p>
<div class="mockup">
<div class="mockup-header">Settings — Integrations (Email tab active)</div>
<div class="mockup-body" style="background:#f9fafb;padding:24px;">
<div style="max-width:680px;margin:0 auto;">
<!-- Page heading -->
<div style="margin-bottom:20px;">
<div style="font-size:20px;font-weight:700;color:#111827;margin-bottom:4px;">Settings — Integrations</div>
<div style="font-size:13px;color:#6b7280;">Configure third-party service integrations.</div>
</div>
<!-- Pill tabs -->
<div style="display:flex;gap:8px;margin-bottom:20px;">
<div style="padding:7px 18px;background:#fff;color:#374151;border:1px solid #d1d5db;border-radius:999px;font-size:13px;font-weight:500;cursor:pointer;">
💬 WhatsApp
</div>
<div style="padding:7px 18px;background:#111827;color:#fff;border-radius:999px;font-size:13px;font-weight:600;cursor:pointer;">
✉️ Email
</div>
</div>
<!-- Email card -->
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;">
<!-- Card header -->
<div style="padding:16px 24px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;gap:12px;">
<div style="width:32px;height:32px;background:#eff6ff;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;">✉️</div>
<div>
<div style="font-size:14px;font-weight:600;color:#111827;">Microsoft 365 (Azure Mail)</div>
<div style="font-size:12px;color:#6b7280;">Send emails via Microsoft Graph API using Azure AD</div>
</div>
</div>
<!-- Form body -->
<div style="padding:24px;">
<!-- Enable toggle -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<div>
<div style="font-size:14px;font-weight:500;color:#374151;margin-bottom:2px;">Enable Email Notifications</div>
<div style="font-size:12px;color:#6b7280;">When disabled, no emails will be sent.</div>
</div>
<div style="width:44px;height:24px;border-radius:12px;background:#d1d5db;position:relative;">
<div style="position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2);"></div>
</div>
</div>
<div style="border-top:1px solid #e5e7eb;margin-bottom:20px;"></div>
<!-- Tenant ID -->
<div style="margin-bottom:16px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Tenant ID</div>
<div style="height:38px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:13px;color:#9ca3af;">xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</span>
</div>
</div>
<!-- Client ID -->
<div style="margin-bottom:16px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Client ID</div>
<div style="height:38px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:13px;color:#9ca3af;">xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</span>
</div>
</div>
<!-- Client Secret -->
<div style="margin-bottom:16px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Client Secret</div>
<div style="height:38px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:13px;color:#9ca3af;letter-spacing:2px;">••••••••••••••••</span>
<span style="font-size:11px;color:#6b7280;background:#f9fafb;padding:2px 8px;border-radius:4px;border:1px solid #e5e7eb;cursor:pointer;">Show</span>
</div>
</div>
<!-- From Address -->
<div style="margin-bottom:24px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">From Address</div>
<div style="height:38px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:13px;color:#9ca3af;">[email protected]</span>
</div>
<div style="font-size:11px;color:#6b7280;margin-top:4px;">Must be a mailbox in your Microsoft 365 tenant.</div>
</div>
<!-- Actions -->
<div style="display:flex;align-items:center;justify-content:space-between;padding-top:16px;border-top:1px solid #f3f4f6;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:13px;color:#2563eb;text-decoration:underline;cursor:pointer;">🔗 Test Connection</span>
</div>
<div style="padding:8px 20px;background:#111827;color:#fff;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;">Save Settings</div>
</div>
</div>
</div>
<!-- Send Test Email accordion -->
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;margin-top:14px;overflow:hidden;">
<div style="padding:14px 24px;display:flex;align-items:center;justify-content:space-between;cursor:pointer;">
<div style="display:flex;align-items:center;gap:10px;">
<span style="font-size:16px;">📧</span>
<span style="font-size:14px;font-weight:600;color:#111827;">Send Test Email</span>
<span style="font-size:12px;color:#6b7280;">— verify the connection works end-to-end</span>
</div>
<span style="font-size:12px;color:#9ca3af;">▼</span>
</div>
<!-- Expanded state shown -->
<div style="padding:0 24px 20px;border-top:1px solid #e5e7eb;">
<div style="height:12px;"></div>
<div style="margin-bottom:12px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">To</div>
<div style="height:38px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:13px;color:#9ca3af;">[email protected]</span>
</div>
</div>
<div style="margin-bottom:12px;">
<div style="font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Subject</div>
<div style="height:38px;background:#fff;border:1px solid #d1d5db;border-radius:7px;padding:0 12px;display:flex;align-items:center;">
<span style="font-size:13px;color:#6b7280;">Test Email from SteelERP</span>
</div>
</div>
<div style="padding:9px 18px;background:#2563eb;color:#fff;border-radius:8px;font-size:13px;font-weight:600;display:inline-flex;align-items:center;gap:7px;cursor:pointer;">
✉️ Send Email
</div>
</div>
</div>
</div>
</div>
</div>
<p class="subtitle" style="margin-top:16px;">Does this look right? Let me know in the terminal if you'd like any changes — different fields, different layout, etc.</p>
@@ -0,0 +1,117 @@
<h2>Integrations Page — Tab Layout</h2>
<p class="subtitle">Here's how the integrations page would look with WhatsApp and Email tabs. Which tab style feels right?</p>
<div class="cards">
<!-- Option A: Pill tabs -->
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image" style="background:#f9fafb;padding:20px;">
<div style="max-width:560px;margin:0 auto;">
<div style="margin-bottom:16px;">
<div style="font-size:18px;font-weight:700;color:#111827;margin-bottom:4px;">Settings — Integrations</div>
<div style="font-size:13px;color:#6b7280;">Configure third-party service integrations.</div>
</div>
<!-- Pill tabs -->
<div style="display:flex;gap:8px;margin-bottom:20px;">
<div style="padding:7px 18px;background:#111827;color:#fff;border-radius:999px;font-size:13px;font-weight:600;cursor:pointer;">
<span style="margin-right:6px;">💬</span>WhatsApp
</div>
<div style="padding:7px 18px;background:#fff;color:#374151;border:1px solid #d1d5db;border-radius:999px;font-size:13px;font-weight:500;cursor:pointer;">
<span style="margin-right:6px;">✉️</span>Email
</div>
</div>
<!-- Card -->
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;">
<div style="padding:16px 20px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;gap:10px;">
<div style="width:10px;height:10px;border-radius:50%;background:#22c55e;"></div>
<span style="font-size:14px;font-weight:600;color:#111827;">WhatsApp (UltraMSG)</span>
<span style="margin-left:auto;font-size:11px;color:#6b7280;background:#f3f4f6;padding:2px 8px;border-radius:999px;">Active</span>
</div>
<div style="padding:16px 20px;">
<div style="height:10px;background:#f3f4f6;border-radius:4px;width:60%;margin-bottom:8px;"></div>
<div style="height:10px;background:#f3f4f6;border-radius:4px;width:40%;"></div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>A — Pill Tabs</h3>
<p>Rounded pill-style tabs. Modern, clean. Active tab is filled dark.</p>
</div>
</div>
<!-- Option B: Underline tabs -->
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image" style="background:#f9fafb;padding:20px;">
<div style="max-width:560px;margin:0 auto;">
<div style="margin-bottom:16px;">
<div style="font-size:18px;font-weight:700;color:#111827;margin-bottom:4px;">Settings — Integrations</div>
<div style="font-size:13px;color:#6b7280;">Configure third-party service integrations.</div>
</div>
<!-- Underline tabs -->
<div style="display:flex;gap:0;border-bottom:2px solid #e5e7eb;margin-bottom:20px;">
<div style="padding:8px 20px;font-size:13px;font-weight:600;color:#111827;border-bottom:2px solid #111827;margin-bottom:-2px;cursor:pointer;">
<span style="margin-right:6px;">💬</span>WhatsApp
</div>
<div style="padding:8px 20px;font-size:13px;font-weight:500;color:#6b7280;cursor:pointer;">
<span style="margin-right:6px;">✉️</span>Email
</div>
</div>
<!-- Card -->
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;">
<div style="padding:16px 20px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;gap:10px;">
<div style="width:10px;height:10px;border-radius:50%;background:#22c55e;"></div>
<span style="font-size:14px;font-weight:600;color:#111827;">WhatsApp (UltraMSG)</span>
<span style="margin-left:auto;font-size:11px;color:#6b7280;background:#f3f4f6;padding:2px 8px;border-radius:999px;">Active</span>
</div>
<div style="padding:16px 20px;">
<div style="height:10px;background:#f3f4f6;border-radius:4px;width:60%;margin-bottom:8px;"></div>
<div style="height:10px;background:#f3f4f6;border-radius:4px;width:40%;"></div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>B — Underline Tabs</h3>
<p>Classic tab style with bottom border indicator. Matches settings pages in most ERP systems.</p>
</div>
</div>
<!-- Option C: Side nav -->
<div class="card" data-choice="c" onclick="toggleSelect(this)">
<div class="card-image" style="background:#f9fafb;padding:20px;">
<div style="max-width:560px;margin:0 auto;">
<div style="margin-bottom:16px;">
<div style="font-size:18px;font-weight:700;color:#111827;margin-bottom:4px;">Settings — Integrations</div>
<div style="font-size:13px;color:#6b7280;">Configure third-party service integrations.</div>
</div>
<!-- Side nav + content -->
<div style="display:flex;gap:16px;">
<div style="width:140px;flex-shrink:0;display:flex;flex-direction:column;gap:4px;">
<div style="padding:9px 14px;background:#111827;color:#fff;border-radius:7px;font-size:13px;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:8px;">
<span>💬</span>WhatsApp
</div>
<div style="padding:9px 14px;color:#374151;border-radius:7px;font-size:13px;font-weight:500;cursor:pointer;display:flex;align-items:center;gap:8px;">
<span>✉️</span>Email
</div>
</div>
<div style="flex:1;background:#fff;border:1px solid #e5e7eb;border-radius:10px;">
<div style="padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;gap:8px;">
<div style="width:8px;height:8px;border-radius:50%;background:#22c55e;"></div>
<span style="font-size:13px;font-weight:600;color:#111827;">WhatsApp (UltraMSG)</span>
</div>
<div style="padding:12px 16px;">
<div style="height:8px;background:#f3f4f6;border-radius:4px;width:60%;margin-bottom:8px;"></div>
<div style="height:8px;background:#f3f4f6;border-radius:4px;width:40%;"></div>
</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>C — Sidebar Nav</h3>
<p>Left sidebar with integration list. Scales well if more integrations are added later.</p>
</div>
</div>
</div>
@@ -0,0 +1,106 @@
<h2>Email Tab — Multi-Account Design</h2>
<p class="subtitle">The Email tab becomes a list of named accounts. Each account has a type badge, from address, and actions. Here's how it looks with a few accounts configured.</p>
<div class="mockup">
<div class="mockup-header">Settings — Integrations (Email tab active)</div>
<div class="mockup-body" style="background:#f9fafb;padding:24px;">
<div style="max-width:680px;margin:0 auto;">
<!-- Page heading -->
<div style="margin-bottom:20px;">
<div style="font-size:20px;font-weight:700;color:#111827;margin-bottom:4px;">Settings — Integrations</div>
<div style="font-size:13px;color:#6b7280;">Configure third-party service integrations.</div>
</div>
<!-- Pill tabs -->
<div style="display:flex;gap:8px;margin-bottom:20px;">
<div style="padding:7px 18px;background:#fff;color:#374151;border:1px solid #d1d5db;border-radius:999px;font-size:13px;font-weight:500;">💬 WhatsApp</div>
<div style="padding:7px 18px;background:#111827;color:#fff;border-radius:999px;font-size:13px;font-weight:600;">✉️ Email</div>
</div>
<!-- Header row: title + Add button -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
<div>
<div style="font-size:15px;font-weight:600;color:#111827;">Mail Accounts</div>
<div style="font-size:12px;color:#6b7280;">3 accounts configured</div>
</div>
<div style="padding:8px 16px;background:#111827;color:#fff;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:7px;">
<span style="font-size:16px;line-height:1;">+</span> Add Account
</div>
</div>
<!-- Account list -->
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;">
<!-- Account 1: Azure -->
<div style="padding:16px 20px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #f3f4f6;">
<div style="width:36px;height:36px;background:#eff6ff;border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:17px;">✉️</div>
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
<span style="font-size:14px;font-weight:600;color:#111827;">customer-support</span>
<span style="font-size:11px;font-weight:600;color:#2563eb;background:#eff6ff;padding:2px 8px;border-radius:999px;">Microsoft 365</span>
</div>
<div style="font-size:12px;color:#6b7280;">[email protected]</div>
</div>
<!-- Toggle: ON -->
<div style="width:44px;height:24px;border-radius:12px;background:#22c55e;position:relative;cursor:pointer;flex-shrink:0;">
<div style="position:absolute;top:2px;left:22px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2);"></div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0;">
<button style="font-size:12px;color:#6b7280;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:5px 10px;cursor:pointer;">Edit</button>
<button style="font-size:12px;color:#dc2626;background:#fff5f5;border:1px solid #fecaca;border-radius:6px;padding:5px 10px;cursor:pointer;">Delete</button>
</div>
</div>
<!-- Account 2: SMTP -->
<div style="padding:16px 20px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #f3f4f6;">
<div style="width:36px;height:36px;background:#f0fdf4;border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:17px;">📧</div>
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
<span style="font-size:14px;font-weight:600;color:#111827;">invoices</span>
<span style="font-size:11px;font-weight:600;color:#16a34a;background:#f0fdf4;padding:2px 8px;border-radius:999px;">SMTP</span>
</div>
<div style="font-size:12px;color:#6b7280;">[email protected] · smtp.gmail.com:587</div>
</div>
<!-- Toggle: ON -->
<div style="width:44px;height:24px;border-radius:12px;background:#22c55e;position:relative;cursor:pointer;flex-shrink:0;">
<div style="position:absolute;top:2px;left:22px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2);"></div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0;">
<button style="font-size:12px;color:#6b7280;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:5px 10px;cursor:pointer;">Edit</button>
<button style="font-size:12px;color:#dc2626;background:#fff5f5;border:1px solid #fecaca;border-radius:6px;padding:5px 10px;cursor:pointer;">Delete</button>
</div>
</div>
<!-- Account 3: SMTP disabled -->
<div style="padding:16px 20px;display:flex;align-items:center;gap:14px;opacity:.6;">
<div style="width:36px;height:36px;background:#f3f4f6;border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:17px;">📧</div>
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
<span style="font-size:14px;font-weight:600;color:#111827;">notifications</span>
<span style="font-size:11px;font-weight:600;color:#16a34a;background:#f0fdf4;padding:2px 8px;border-radius:999px;">SMTP</span>
</div>
<div style="font-size:12px;color:#6b7280;">[email protected] · mail.steelco.com:465</div>
</div>
<!-- Toggle: OFF -->
<div style="width:44px;height:24px;border-radius:12px;background:#d1d5db;position:relative;cursor:pointer;flex-shrink:0;">
<div style="position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2);"></div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0;">
<button style="font-size:12px;color:#6b7280;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:5px 10px;cursor:pointer;">Edit</button>
<button style="font-size:12px;color:#dc2626;background:#fff5f5;border:1px solid #fecaca;border-radius:6px;padding:5px 10px;cursor:pointer;">Delete</button>
</div>
</div>
</div>
<!-- Empty state hint -->
<div style="margin-top:10px;font-size:12px;color:#9ca3af;text-align:center;">
Use account names in code: <code style="background:#f3f4f6;padding:2px 6px;border-radius:4px;font-size:11px;">Mail::mailer('customer-support')</code>
</div>
</div>
</div>
</div>
<p class="subtitle" style="margin-top:16px;">This is the Email tab redesigned as a list. Click "Add Account" opens a modal (next screen). Does this list layout look right? Let me know in the terminal.</p>
@@ -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>
@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Design approved — writing the implementation plan in terminal...</p>
</div>
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1779788510216}
@@ -0,0 +1 @@
2033
@@ -71,7 +71,7 @@ class MailAccountController extends Controller
$mailer = new \Illuminate\Mail\Mailer(
$mailAccount->name,
app('view'),
new \Symfony\Component\Mailer\Mailer($mailAccount->buildTransport()),
$mailAccount->buildTransport(),
app('events')
);
$mailer->raw(
@@ -84,7 +84,12 @@ class MailAccountController extends Controller
);
return response()->json(['success' => true]);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('sendTestEmail failed: ' . $e->getMessage(), ['account' => $mailAccount->name]);
\Illuminate\Support\Facades\Log::error('sendTestEmail failed', [
'account' => $mailAccount->name,
'from' => $mailAccount->from_address,
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json(['success' => false, 'message' => $e->getMessage()]);
}
}
@@ -35,7 +35,8 @@ class PurchasePipelineController extends Controller
'items',
'signature.signedBy',
'rfqInvitations.supplier',
'supplierQuotes',
'supplierQuotes.supplier',
'supplierQuotes.items',
'awardedQuote.supplier',
]);
@@ -36,6 +36,9 @@ class RfqController extends Controller
}
if (empty($supplierItems)) {
if ($request->expectsJson()) {
return response()->json(['message' => 'Please assign at least one supplier to an item.'], 422);
}
return redirect()->back()->with('error', 'Please assign at least one supplier to an item.');
}
@@ -67,6 +70,22 @@ class RfqController extends Controller
$stages->setStage($purchaseRequest, 'rfq');
if ($request->expectsJson()) {
$invitations = $purchaseRequest->rfqInvitations()->with('supplier')->get()->map(function ($inv) {
return [
'supplier_name' => $inv->supplier->name,
'channel' => $inv->channel,
'url' => route('rfq.show', $inv->token),
'status' => $inv->status,
];
});
return response()->json([
'added' => $added,
'invitations' => $invitations,
'redirect' => route('purchase.pipeline.show', $purchaseRequest),
]);
}
return redirect()->route('purchase.pipeline.show', $purchaseRequest)
->with('success', $added . ' supplier(s) added. Now send them the quote request links.');
}
@@ -4,8 +4,11 @@ namespace App\Http\Controllers\Purchase;
use App\Http\Controllers\Controller;
use App\Models\RfqInvitation;
use App\Models\Setting;
use App\Models\SupplierQuote;
use App\Models\SupplierQuoteItem;
use App\Models\User;
use App\Notifications\QuoteReceived;
use App\Services\PurchaseStageService;
use Illuminate\Http\Request;
@@ -35,9 +38,18 @@ class RfqPortalController extends Controller
}
$purchaseRequest = $invitation->purchaseRequest;
$items = $purchaseRequest->items;
$itemIds = $invitation->item_ids;
$items = $itemIds
? $purchaseRequest->items->whereIn('id', $itemIds)->values()
: $purchaseRequest->items;
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items'));
// Generate a fresh confirmation code per page load and store in session
$confirmCode = strtoupper(substr(bin2hex(random_bytes(3)), 0, 5));
session(['rfq_confirm_' . $token => $confirmCode]);
$vatRate = (float) Setting::get('vat_rate', 0);
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items', 'confirmCode', 'vatRate'));
}
public function submit(Request $request, string $token)
@@ -49,14 +61,28 @@ class RfqPortalController extends Controller
}
$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'],
'terms' => ['accepted'],
'confirm_code' => ['required', 'string'],
'lead_time_days' => ['nullable', 'integer', 'min:0'],
'payment_terms' => ['nullable', 'string', 'max:200'],
'notes' => ['nullable', 'string', 'max:1000'],
'items' => ['required', 'array'],
'items.*.unit_price' => ['nullable', 'numeric', 'min:0'],
'items.*.is_vatable' => ['nullable', 'boolean'],
'items.*.not_available' => ['nullable', 'boolean'],
'items.*.supplier_description' => ['nullable', 'string', 'max:500'],
]);
$purchaseItems = $invitation->purchaseRequest->items;
$expectedCode = session('rfq_confirm_' . $token);
if (!$expectedCode || strtoupper(trim($validated['confirm_code'])) !== $expectedCode) {
return back()->withErrors(['confirm_code' => 'Incorrect confirmation code. Please copy the code exactly as shown.'])->withInput();
}
session()->forget('rfq_confirm_' . $token);
$itemIds = $invitation->item_ids;
$purchaseItems = $itemIds
? $invitation->purchaseRequest->items->whereIn('id', $itemIds)->values()
: $invitation->purchaseRequest->items;
$quote = SupplierQuote::create([
'rfq_invitation_id' => $invitation->id,
@@ -69,24 +95,40 @@ class RfqPortalController extends Controller
'total_amount' => 0,
]);
$total = 0;
$subtotal = 0;
$vatAmount = 0;
$vatRate = (float) Setting::get('vat_rate', 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;
$notAvailable = !empty($validated['items'][$i]['not_available']);
$unitPrice = $notAvailable ? 0 : (float)($validated['items'][$i]['unit_price'] ?? 0);
$qty = (float)$item->quantity_required;
$totalPrice = $notAvailable ? 0 : round($unitPrice * $qty, 3);
$isVatable = !$notAvailable && !empty($validated['items'][$i]['is_vatable']);
$supplierDescription = !empty($validated['items'][$i]['supplier_description'])
? trim($validated['items'][$i]['supplier_description'])
: null;
$subtotal += $totalPrice;
if ($isVatable && $vatRate > 0) {
$vatAmount += round($totalPrice * $vatRate / 100, 3);
}
SupplierQuoteItem::create([
'supplier_quote_id' => $quote->id,
'description' => $item->description,
'unit' => $item->unit ?? '',
'quantity' => $qty,
'unit_price' => $unitPrice,
'total_price' => $totalPrice,
'supplier_quote_id' => $quote->id,
'description' => $item->description,
'supplier_description'=> $supplierDescription,
'unit' => $item->unit ?? '',
'quantity' => $qty,
'unit_price' => $unitPrice,
'total_price' => $totalPrice,
'is_vatable' => $isVatable,
'not_available' => $notAvailable,
]);
}
$quote->update(['total_amount' => round($total, 3)]);
$quote->update(['total_amount' => round($subtotal + $vatAmount, 3)]);
$invitation->update(['status' => 'submitted']);
// If at least 1 quote is in, move to comparison stage
@@ -95,6 +137,10 @@ class RfqPortalController extends Controller
app(PurchaseStageService::class)->setStage($pr, 'comparison');
}
// Notify all admin users
$invitation->load('supplier', 'purchaseRequest');
User::role('Admin')->each(fn($u) => $u->notify(new QuoteReceived($invitation)));
return view('rfq.submitted', compact('invitation'));
}
}
@@ -3,52 +3,93 @@
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Settings\Company;
use App\Models\Settings\Department;
use App\Models\Settings\Location;
use App\Models\Settings\ProjectSetting;
use App\Services\ProjectImportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class ProjectSettingController extends Controller
{
public function index()
{
$projects = ProjectSetting::with(['locations' => function ($q) {
$q->orderBy('name');
}])->orderBy('name')->get();
$companies = Company::with([
'projects.locations' => fn ($q) => $q->orderBy('name'),
'departments' => fn ($q) => $q->orderBy('name'),
])->orderBy('name')->get();
$allProjects = $companies->flatMap(fn ($c) => $c->projects);
$stats = [
'total_projects' => $projects->count(),
'active_projects' => $projects->where('is_active', true)->count(),
'total_locations' => $projects->sum(fn ($p) => $p->locations->count()),
'active_locations' => $projects->sum(fn ($p) => $p->locations->where('is_active', true)->count()),
'total_companies' => $companies->count(),
'total_projects' => $allProjects->count(),
'active_projects' => $allProjects->where('is_active', true)->count(),
'total_locations' => $allProjects->sum(fn ($p) => $p->locations->count()),
'total_departments' => $companies->sum(fn ($c) => $c->departments->count()),
];
return view('settings.projects.index', compact('projects', 'stats'));
return view('settings.projects.index', compact('companies', 'stats'));
}
public function store(Request $request)
{
$validated = $request->validate(['name' => 'required|string|max:255|unique:settings_projects,name']);
$project = ProjectSetting::create(['name' => $validated['name'], 'is_active' => true]);
$validated = $request->validate([
'name' => 'required|string|max:255|unique:settings_projects,name',
'company_id' => 'required|exists:settings_companies,id',
]);
$project = ProjectSetting::create(['name' => $validated['name'], 'company_id' => $validated['company_id'], 'is_active' => true]);
return response()->json(['project' => [
'id' => $project->id,
'name' => $project->name,
'is_active' => $project->is_active,
'id' => $project->id,
'name' => $project->name,
'is_active' => $project->is_active,
'company_id' => $project->company_id,
]]);
}
// ── Company CRUD ──────────────────────────────────────────────────────────
public function storeCompany(Request $request)
{
$validated = $request->validate(['name' => 'required|string|max:255|unique:settings_companies,name']);
$company = Company::create(['name' => $validated['name'], 'is_active' => true]);
return response()->json(['company' => ['id' => $company->id, 'name' => $company->name, 'is_active' => $company->is_active]]);
}
public function updateCompany(Request $request, Company $company)
{
$validated = $request->validate(['name' => 'required|string|max:255|unique:settings_companies,name,' . $company->id]);
$company->update(['name' => $validated['name'], 'is_active' => $request->boolean('is_active', true)]);
return response()->json(['company' => ['id' => $company->id, 'name' => $company->name, 'is_active' => $company->is_active]]);
}
public function destroyCompany(Company $company)
{
$company->delete();
return response()->json(['ok' => true]);
}
public function update(Request $request, ProjectSetting $project)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:settings_projects,name,' . $project->id,
'name' => 'required|string|max:255|unique:settings_projects,name,' . $project->id,
'company_id' => 'nullable|exists:settings_companies,id',
]);
$project->update([
'name' => $validated['name'],
'is_active' => $request->boolean('is_active', true),
'name' => $validated['name'],
'company_id' => $validated['company_id'] ?? $project->company_id,
'is_active' => $request->boolean('is_active', true),
]);
return response()->json(['project' => [
'id' => $project->id,
'name' => $project->name,
'is_active' => $project->is_active,
'id' => $project->id,
'name' => $project->name,
'is_active' => $project->is_active,
'company_id' => $project->company_id,
]]);
}
@@ -113,4 +154,133 @@ class ProjectSettingController extends Controller
$location->delete();
return response()->json(['ok' => true]);
}
public function storeDepartment(Request $request, Company $company)
{
$validated = $request->validate(['name' => 'required|string|max:255']);
$dept = $company->departments()->create(['name' => $validated['name'], 'is_active' => true]);
return response()->json(['department' => ['id' => $dept->id, 'name' => $dept->name, 'is_active' => $dept->is_active]]);
}
public function updateDepartment(Request $request, Company $company, Department $department)
{
$validated = $request->validate(['name' => 'required|string|max:255']);
$department->update(['name' => $validated['name'], 'is_active' => $request->boolean('is_active', true)]);
return response()->json(['department' => ['id' => $department->id, 'name' => $department->name, 'is_active' => $department->is_active]]);
}
public function destroyDepartment(Company $company, Department $department)
{
$department->delete();
return response()->json(['ok' => true]);
}
// ── Import ────────────────────────────────────────────────────────────────
public function import(Request $request): JsonResponse
{
$request->validate(['file' => 'required|file|mimes:xlsx,xls|max:10240']);
try {
$stats = app(ProjectImportService::class)->import(
$request->file('file')->getPathname()
);
$parts = [];
if ($stats['projects_created']) $parts[] = "{$stats['projects_created']} project(s)";
if ($stats['locations_created']) $parts[] = "{$stats['locations_created']} location(s)";
if ($stats['departments_created']) $parts[] = "{$stats['departments_created']} department(s)";
if ($stats['companies_created']) $parts[] = "{$stats['companies_created']} new company(s)";
$message = $parts
? 'Imported: ' . implode(', ', $parts) . ($stats['skipped'] ? " — {$stats['skipped']} row(s) skipped" : '')
: 'Nothing new to import' . ($stats['skipped'] ? " ({$stats['skipped']} rows already exist)" : '');
return response()->json(['success' => true, 'message' => $message, 'stats' => $stats]);
} catch (\Exception $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 422);
}
}
public function downloadTemplate(): \Symfony\Component\HttpFoundation\BinaryFileResponse
{
$path = storage_path('app/projects_template.xlsx');
$this->buildTemplate($path);
return response()->download($path, 'projects_template.xlsx');
}
private function buildTemplate(string $path): void
{
$spreadsheet = new Spreadsheet();
$headerStyle = [
'font' => ['bold' => true, 'color' => ['rgb' => '1e293b']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'DBEAFE']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_LEFT],
];
$noteStyle = [
'font' => ['italic' => true, 'color' => ['rgb' => '64748b'], 'size' => 10],
];
// ── Sheet 1: Projects (with optional locations) ────────────────────
$s1 = $spreadsheet->getActiveSheet()->setTitle('Projects');
$s1->setCellValue('A1', 'Company Name')
->setCellValue('B1', 'Project Name')
->setCellValue('C1', 'Location Name')
->setCellValue('D1', 'Address')
->setCellValue('E1', 'Latitude')
->setCellValue('F1', 'Longitude');
$s1->getStyle('A1:F1')->applyFromArray($headerStyle);
$samples = [
['Miknas Industrial', 'New Warehouse', 'Main Gate', 'Industrial Area, Block 5', '24.7136', '46.6753'],
['Miknas Industrial', 'New Warehouse', 'Storage Yard', '', '', ''],
['Steel tech', 'Factory Extension','Site Office', '2nd Ring Road, Riyadh', '24.6877', '46.7219'],
['Steel tech', 'New Office Block', '', '', '', ''],
];
foreach ($samples as $i => $row) {
$s1->setCellValue('A' . ($i + 2), $row[0]);
$s1->setCellValue('B' . ($i + 2), $row[1]);
$s1->setCellValue('C' . ($i + 2), $row[2]);
$s1->setCellValue('D' . ($i + 2), $row[3]);
$s1->setCellValue('E' . ($i + 2), $row[4]);
$s1->setCellValue('F' . ($i + 2), $row[5]);
}
$s1->setCellValue('A7', '* Leave Location Name blank for rows that are projects only. Company is created automatically. Duplicates are skipped.');
$s1->getStyle('A7')->applyFromArray($noteStyle);
$s1->mergeCells('A7:F7');
$s1->getColumnDimension('A')->setWidth(28);
$s1->getColumnDimension('B')->setWidth(28);
$s1->getColumnDimension('C')->setWidth(24);
$s1->getColumnDimension('D')->setWidth(32);
$s1->getColumnDimension('E')->setWidth(14);
$s1->getColumnDimension('F')->setWidth(14);
// ── Sheet 2: Departments ───────────────────────────────────────────
$s2 = new Worksheet($spreadsheet, 'Departments');
$spreadsheet->addSheet($s2);
$s2->setCellValue('A1', 'Company Name')
->setCellValue('B1', 'Department Name');
$s2->getStyle('A1:B1')->applyFromArray($headerStyle);
$deptSamples = [
['Miknas Industrial', 'Finance'],
['Miknas Industrial', 'Operations'],
['Steel tech', 'Production'],
];
foreach ($deptSamples as $i => $row) {
$s2->setCellValue('A' . ($i + 2), $row[0]);
$s2->setCellValue('B' . ($i + 2), $row[1]);
}
$s2->setCellValue('A6', '* Delete sample rows before importing. Company will be created if it does not exist.');
$s2->getStyle('A6')->applyFromArray($noteStyle);
$s2->mergeCells('A6:B6');
$s2->getColumnDimension('A')->setWidth(32);
$s2->getColumnDimension('B')->setWidth(32);
(new Xlsx($spreadsheet))->save($path);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\Request;
class VatSettingController extends Controller
{
public function index()
{
$vatRate = Setting::get('vat_rate', '0');
return view('settings.vat', compact('vatRate'));
}
public function update(Request $request)
{
$validated = $request->validate([
'vat_rate' => ['required', 'numeric', 'min:0', 'max:100'],
]);
Setting::set('vat_rate', (string) $validated['vat_rate']);
return response()->json(['message' => 'VAT rate saved.', 'vat_rate' => $validated['vat_rate']]);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models\Settings;
use Illuminate\Database\Eloquent\Model;
class Company extends Model
{
protected $table = 'settings_companies';
protected $fillable = ['name', 'is_active'];
protected $casts = ['is_active' => 'boolean'];
public function projects(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(ProjectSetting::class, 'company_id');
}
public function departments(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Department::class, 'company_id');
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Models\Settings;
use Illuminate\Database\Eloquent\Model;
class Department extends Model
{
protected $table = 'settings_departments';
protected $fillable = ['name', 'company_id', 'is_active'];
protected $casts = ['is_active' => 'boolean'];
public function company(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Company::class, 'company_id');
}
}
+7 -1
View File
@@ -8,7 +8,7 @@ class ProjectSetting extends Model
{
protected $table = 'settings_projects';
protected $fillable = ['name', 'is_active'];
protected $fillable = ['name', 'is_active', 'company_id'];
protected $casts = ['is_active' => 'boolean'];
@@ -17,8 +17,14 @@ class ProjectSetting extends Model
return $query->where('is_active', true);
}
public function company(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Company::class, 'company_id');
}
public function locations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(\App\Models\Settings\Location::class, 'project_id');
}
}
+7 -1
View File
@@ -7,7 +7,13 @@ use Illuminate\Database\Eloquent\Model;
class SupplierQuoteItem extends Model
{
protected $fillable = [
'supplier_quote_id', 'description', 'unit', 'quantity', 'unit_price', 'total_price',
'supplier_quote_id', 'description', 'supplier_description', 'unit', 'quantity',
'unit_price', 'total_price', 'is_vatable', 'not_available',
];
protected $casts = [
'is_vatable' => 'boolean',
'not_available' => 'boolean',
];
public function quote()
+4 -1
View File
@@ -41,8 +41,11 @@ class User extends Authenticatable
*
* @return array<string, string>
*/
public function routeNotificationFor(string $channel, mixed $notification = null): ?string
public function routeNotificationFor(string $channel, mixed $notification = null): mixed
{
if ($channel === 'database') {
return $this->notifications();
}
return $this->whatsapp_number;
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Notifications;
use App\Models\RfqInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class QuoteReceived extends Notification
{
use Queueable;
public function __construct(public RfqInvitation $invitation) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toDatabase(object $notifiable): array
{
$pr = $this->invitation->purchaseRequest;
return [
'type' => 'quote_received',
'message' => $this->invitation->supplier->name . ' submitted a quote for ' . $pr->request_number,
'supplier_name' => $this->invitation->supplier->name,
'request_number' => $pr->request_number,
'purchase_request_id' => $pr->id,
'url' => route('purchase.pipeline.show', $pr),
];
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
namespace App\Services;
use App\Models\Settings\Company;
use App\Models\Settings\Department;
use App\Models\Settings\ProjectSetting;
use PhpOffice\PhpSpreadsheet\IOFactory;
class ProjectImportService
{
private array $stats = [
'companies_created' => 0,
'projects_created' => 0,
'locations_created' => 0,
'departments_created' => 0,
'skipped' => 0,
];
/** @var array<string,Company> */
private array $companyCache = [];
public function import(string $filePath): array
{
$spreadsheet = IOFactory::load($filePath);
$projectSheet = $this->findSheet($spreadsheet, ['projects', 'project']);
$deptSheet = $this->findSheet($spreadsheet, ['departments', 'department', 'depts', 'dept']);
if ($projectSheet) {
$this->importProjects($projectSheet);
}
if ($deptSheet) {
$this->importDepartments($deptSheet);
}
// Single-sheet file with no named tabs → treat as projects
if (!$projectSheet && !$deptSheet) {
$this->importProjects($spreadsheet->getActiveSheet());
}
return $this->stats;
}
private function findSheet($spreadsheet, array $names): ?\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet
{
foreach ($spreadsheet->getSheetNames() as $i => $sheetName) {
if (in_array(strtolower(trim($sheetName)), $names)) {
return $spreadsheet->getSheet($i);
}
}
return null;
}
private function importProjects(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet): void
{
$rows = $sheet->toArray(null, true, true, false);
$headers = $this->normalizeHeaders((array) array_shift($rows));
$coIdx = $this->findCol($headers, ['company', 'company name', 'companyname']);
$projIdx = $this->findCol($headers, ['project', 'project name', 'projectname']);
$locIdx = $this->findCol($headers, ['location', 'location name', 'locationname', 'loc', 'loc name']);
$addrIdx = $this->findCol($headers, ['address', 'addr']);
$latIdx = $this->findCol($headers, ['latitude', 'lat']);
$lngIdx = $this->findCol($headers, ['longitude', 'lng', 'lon', 'long']);
if ($coIdx === null || $projIdx === null) {
return;
}
foreach ($rows as $row) {
$coName = $this->str($row[$coIdx] ?? null);
$projName = $this->str($row[$projIdx] ?? null);
if (!$coName || !$projName) {
$this->stats['skipped']++;
continue;
}
$company = $this->findOrCreateCompany($coName);
$project = ProjectSetting::whereRaw('LOWER(name) = ?', [strtolower($projName)])
->where('company_id', $company->id)->first();
if (!$project) {
$project = ProjectSetting::create(['name' => $projName, 'company_id' => $company->id, 'is_active' => true]);
$this->stats['projects_created']++;
}
// If a location name is present on this row, import it too
$locName = $locIdx !== null ? $this->str($row[$locIdx] ?? null) : null;
if ($locName) {
$existingLoc = $project->locations()
->whereRaw('LOWER(name) = ?', [strtolower($locName)])->first();
if ($existingLoc) {
$this->stats['skipped']++;
} else {
$address = $addrIdx !== null ? $this->str($row[$addrIdx] ?? null) : null;
$lat = $latIdx !== null ? $this->str($row[$latIdx] ?? null) : null;
$lng = $lngIdx !== null ? $this->str($row[$lngIdx] ?? null) : null;
$project->locations()->create([
'name' => $locName,
'address' => $address,
'latitude' => is_numeric($lat) ? (float) $lat : null,
'longitude' => is_numeric($lng) ? (float) $lng : null,
'is_active' => true,
]);
$this->stats['locations_created']++;
}
}
}
}
private function importDepartments(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet): void
{
$rows = $sheet->toArray(null, true, true, false);
$headers = $this->normalizeHeaders((array) array_shift($rows));
$coIdx = $this->findCol($headers, ['company', 'company name', 'companyname']);
$deptIdx = $this->findCol($headers, ['department', 'department name', 'departmentname', 'dept', 'dept name']);
if ($coIdx === null || $deptIdx === null) {
return;
}
foreach ($rows as $row) {
$coName = $this->str($row[$coIdx] ?? null);
$deptName = $this->str($row[$deptIdx] ?? null);
if (!$coName || !$deptName) {
$this->stats['skipped']++;
continue;
}
$company = $this->findOrCreateCompany($coName);
$existing = Department::whereRaw('LOWER(name) = ?', [strtolower($deptName)])
->where('company_id', $company->id)->first();
if ($existing) {
$this->stats['skipped']++;
continue;
}
Department::create(['name' => $deptName, 'company_id' => $company->id, 'is_active' => true]);
$this->stats['departments_created']++;
}
}
private function findOrCreateCompany(string $name): Company
{
$key = strtolower($name);
if (isset($this->companyCache[$key])) {
return $this->companyCache[$key];
}
$company = Company::whereRaw('LOWER(name) = ?', [$key])->first();
if (!$company) {
$company = Company::create(['name' => $name, 'is_active' => true]);
$this->stats['companies_created']++;
}
$this->companyCache[$key] = $company;
return $company;
}
private function normalizeHeaders(array $row): array
{
return array_map(
fn ($h) => strtolower(trim(str_replace(['*', '_', '-'], ' ', (string) ($h ?? '')))),
$row
);
}
private function findCol(array $headers, array $aliases): ?int
{
foreach ($headers as $i => $h) {
if (in_array($h, $aliases)) {
return $i;
}
}
return null;
}
private function str(mixed $value): ?string
{
$v = trim((string) ($value ?? ''));
return $v === '' ? null : $v;
}
}
+2 -2
View File
@@ -8,11 +8,11 @@
"repositories": [
{
"type": "path",
"url": "../ultra-message"
"url": "./packages/ultra-message"
},
{
"type": "path",
"url": "../azure-mailer"
"url": "./packages/azure-mailer"
}
],
"require": {
Generated
+4 -4
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5daed94f08a2aff0bba86affd37a0cd6",
"content-hash": "644cad003bc58ba3ea106336e06e0396",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
@@ -3283,8 +3283,8 @@
"version": "dev-master",
"dist": {
"type": "path",
"url": "../azure-mailer",
"reference": "d240f4f64afcd094b30cd460b82f809f3b2003f9"
"url": "./packages/azure-mailer",
"reference": "b6d04a3e3a01a8439853efe8f52f672ae8a04b75"
},
"require": {
"illuminate/cache": "^11.0|^12.0",
@@ -3334,7 +3334,7 @@
"version": "dev-master",
"dist": {
"type": "path",
"url": "../ultra-message",
"url": "./packages/ultra-message",
"reference": "56160235808f7d9d9dd3ec8d0eefa20a17807647"
},
"require": {
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('settings_departments', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained('settings_projects')->cascadeOnDelete();
$table->string('name', 255);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('settings_departments');
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('settings_companies', function (Blueprint $table) {
$table->id();
$table->string('name', 255)->unique();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::table('settings_projects', function (Blueprint $table) {
$table->foreignId('company_id')->nullable()->after('id')
->constrained('settings_companies')->nullOnDelete();
});
// Assign all existing projects to a "General" company
if (\DB::table('settings_projects')->exists()) {
$id = \DB::table('settings_companies')->insertGetId(['name' => 'General', 'is_active' => 1, 'created_at' => now(), 'updated_at' => now()]);
\DB::table('settings_projects')->update(['company_id' => $id]);
}
}
public function down(): void
{
Schema::table('settings_projects', function (Blueprint $table) {
$table->dropForeign(['company_id']);
$table->dropColumn('company_id');
});
Schema::dropIfExists('settings_companies');
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Recreate table with company_id instead of project_id (SQLite-safe)
Schema::create('settings_departments_new', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('settings_companies')->onDelete('cascade');
$table->string('name');
$table->boolean('is_active')->default(true);
$table->timestamps();
});
// Migrate: map each department's project_id → that project's company_id
DB::statement('
INSERT INTO settings_departments_new (id, company_id, name, is_active, created_at, updated_at)
SELECT d.id, p.company_id, d.name, d.is_active, d.created_at, d.updated_at
FROM settings_departments d
LEFT JOIN settings_projects p ON p.id = d.project_id
WHERE p.company_id IS NOT NULL
');
Schema::drop('settings_departments');
Schema::rename('settings_departments_new', 'settings_departments');
}
public function down(): void
{
Schema::create('settings_departments_old', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained('settings_projects')->onDelete('cascade');
$table->string('name');
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::drop('settings_departments');
Schema::rename('settings_departments_old', 'settings_departments');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notifications');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('supplier_quote_items', function (Blueprint $table) {
$table->boolean('is_vatable')->default(false)->after('total_price');
});
}
public function down(): void
{
Schema::table('supplier_quote_items', function (Blueprint $table) {
$table->dropColumn('is_vatable');
});
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('supplier_quote_items', function (Blueprint $table) {
$table->string('supplier_description')->nullable()->after('description');
$table->boolean('not_available')->default(false)->after('is_vatable');
});
}
public function down(): void
{
Schema::table('supplier_quote_items', function (Blueprint $table) {
$table->dropColumn(['supplier_description', 'not_available']);
});
}
};
@@ -0,0 +1,772 @@
# Integrations Tabs Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add pill-style WhatsApp / Email tabs to the Settings → Integrations page, convert WhatsApp save to AJAX, and add a full Microsoft 365 (Azure Mail) configuration panel.
**Architecture:** Three-file change — routes add 3 new Azure POST endpoints, SettingsController gains 3 new methods and converts `updateWhatsapp()` from redirect to JSON, and `integrations.blade.php` is fully rewritten with Alpine.js tab switching, AJAX saves, and the new Email panel. No new models or migrations needed; all Azure credentials are persisted via the existing `Setting::get/set()` key-value store.
**Tech Stack:** Laravel 12, Alpine.js v3, `PromoSeven\AzureMailer\Graph\TokenManager` (local path package), `Illuminate\Support\Facades\Mail`, `Setting` model key-value store.
---
## File Map
| File | Change |
|------|--------|
| `routes/web.php` | Add 3 POST routes inside the existing `role:Admin` group |
| `app/Http/Controllers/SettingsController.php` | Update `integrations()`, convert `updateWhatsapp()` to JSON, add `updateAzureMail()`, `testAzureMailConnection()`, `sendTestEmail()` |
| `resources/views/settings/integrations.blade.php` | Full rewrite — pill tabs, WhatsApp panel (AJAX), Email panel with all fields + accordions |
---
### Task 1: Add Azure routes
**Files:**
- Modify: `routes/web.php` (around line 125, inside the `role:Admin` group after `send-test-message`)
- [ ] **Step 1: Open `routes/web.php` and locate the settings group**
Find the block that looks like:
```php
Route::post('settings/integrations/send-test-message', [SettingsController::class, 'sendTestMessage'])->name('settings.integrations.send-test-message');
```
- [ ] **Step 2: Add the three Azure routes immediately after that line**
```php
Route::post('settings/integrations/azure-mail', [SettingsController::class, 'updateAzureMail'])->name('settings.integrations.azure-mail');
Route::post('settings/integrations/test-azure-mail', [SettingsController::class, 'testAzureMailConnection'])->name('settings.integrations.test-azure-mail');
Route::post('settings/integrations/send-test-email', [SettingsController::class, 'sendTestEmail'])->name('settings.integrations.send-test-email');
```
- [ ] **Step 3: Verify routes are registered**
Run:
```bash
php artisan route:list --name=settings.integrations
```
Expected: 7 rows — the 4 existing + the 3 new azure ones.
- [ ] **Step 4: Commit**
```bash
git add routes/web.php
git commit -m "feat: add Azure Mail routes to settings integrations"
```
---
### Task 2: Update SettingsController
**Files:**
- Modify: `app/Http/Controllers/SettingsController.php`
The class currently has 4 methods. This task updates `integrations()`, converts `updateWhatsapp()` return type, and adds 3 new methods.
- [ ] **Step 1: Add the new use statements at the top of the file (after the existing ones)**
Add these two lines alongside the existing `use` block:
```php
use Illuminate\Support\Facades\Mail;
use PromoSeven\AzureMailer\Graph\TokenManager;
```
- [ ] **Step 2: Update `integrations()` to fetch and pass Azure settings**
Replace the entire `integrations()` method with:
```php
public function integrations(): View
{
$whatsappSettings = [
'enabled' => Setting::get('ultramsg_enabled', false),
'instance_id' => Setting::get('ultramsg_instance_id', ''),
'token' => Setting::get('ultramsg_token', ''),
'webhook_secret' => Setting::get('ultramsg_webhook_secret', ''),
'webhook_path' => Setting::get('ultramsg_webhook_path', 'ultra-message/webhook'),
];
$azureSettings = [
'enabled' => Setting::get('azure_mail_enabled', false),
'tenant_id' => Setting::get('azure_mail_tenant_id', ''),
'client_id' => Setting::get('azure_mail_client_id', ''),
'client_secret' => Setting::get('azure_mail_client_secret', ''),
'from_address' => Setting::get('azure_mail_from_address', ''),
];
return view('settings.integrations', compact('whatsappSettings', 'azureSettings'));
}
```
- [ ] **Step 3: Convert `updateWhatsapp()` to return JsonResponse instead of RedirectResponse**
Replace the entire `updateWhatsapp()` method with:
```php
public function updateWhatsapp(Request $request): JsonResponse
{
$request->validate([
'instance_id' => ['required', 'string', 'max:100'],
'token' => ['required', 'string', 'max:255'],
'webhook_secret' => ['nullable', 'string', 'max:255'],
'webhook_path' => ['required', 'string', 'max:100'],
]);
Setting::set('ultramsg_enabled', $request->input('enabled') === '1' ? '1' : '0');
Setting::set('ultramsg_instance_id', $request->instance_id);
Setting::set('ultramsg_token', $request->token);
Setting::set('ultramsg_webhook_secret', $request->webhook_secret ?? '');
Setting::set('ultramsg_webhook_path', $request->webhook_path);
return response()->json(['success' => true]);
}
```
Note: `$request->input('enabled') === '1'` because the AJAX payload sends the string `'1'` or `'0'` from the hidden input — not a checkbox boolean.
- [ ] **Step 4: Remove the `RedirectResponse` use statement if it is now unused**
Check if `RedirectResponse` appears anywhere else in the file. If only `updateWhatsapp()` used it, remove:
```php
use Illuminate\Http\RedirectResponse;
```
- [ ] **Step 5: Add `updateAzureMail()` after the closing brace of `updateWhatsapp()`**
```php
public function updateAzureMail(Request $request): JsonResponse
{
$request->validate([
'tenant_id' => ['required', 'string', 'max:100'],
'client_id' => ['required', 'string', 'max:100'],
'client_secret' => ['required', 'string', 'max:500'],
'from_address' => ['required', 'email', 'max:255'],
]);
Setting::set('azure_mail_enabled', $request->input('enabled') === '1' ? '1' : '0');
Setting::set('azure_mail_tenant_id', $request->tenant_id);
Setting::set('azure_mail_client_id', $request->client_id);
Setting::set('azure_mail_client_secret', $request->client_secret);
Setting::set('azure_mail_from_address', $request->from_address);
return response()->json(['success' => true]);
}
```
- [ ] **Step 6: Add `testAzureMailConnection()` after `updateAzureMail()`**
```php
public function testAzureMailConnection(): JsonResponse
{
try {
$config = [
'tenant_id' => Setting::get('azure_mail_tenant_id', ''),
'client_id' => Setting::get('azure_mail_client_id', ''),
'client_secret' => Setting::get('azure_mail_client_secret', ''),
];
$tokenManager = new TokenManager($config);
$tokenManager->getToken();
return response()->json(['success' => true]);
} catch (\Exception $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()]);
}
}
```
- [ ] **Step 7: Add `sendTestEmail()` after `testAzureMailConnection()`**
```php
public function sendTestEmail(Request $request): JsonResponse
{
$request->validate([
'to' => ['required', 'email', 'max:255'],
'subject' => ['required', 'string', 'max:255'],
]);
try {
$to = $request->to;
$subject = $request->subject;
Mail::mailer('azure')->raw(
'This is a test email from SteelERP.',
function ($message) use ($to, $subject) {
$message->to($to)->subject($subject);
}
);
return response()->json(['success' => true]);
} catch (\Exception $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()]);
}
}
```
- [ ] **Step 8: Verify no PHP syntax errors**
Run:
```bash
php artisan route:list --name=settings.integrations
```
Expected: command succeeds with no parse errors (Laravel would fail to boot otherwise).
- [ ] **Step 9: Commit**
```bash
git add app/Http/Controllers/SettingsController.php
git commit -m "feat: update SettingsController for Azure Mail tab — AJAX whatsapp save, 3 new azure methods"
```
---
### Task 3: Rewrite integrations.blade.php
**Files:**
- Modify: `resources/views/settings/integrations.blade.php` (full rewrite)
This task replaces the entire file. Read the current file first to understand what the WhatsApp SVG icon markup and toggle look like, then replace with the complete tabbed layout below.
Key design decisions:
- Outer wrapper uses `x-data="{ tab: 'whatsapp' }"` for Alpine.js tab state
- Pill tabs use `:style` bindings for active/inactive appearance (inline styles per Tailwind JIT rule)
- Email panel uses `x-show="tab==='email'" style="display:none;"` — the `style="display:none"` prevents flash before Alpine initialises
- WhatsApp panel uses `x-show="tab==='whatsapp'"` (no initial display:none because it's the default visible tab)
- Both toggles use a hidden `<input type="hidden">` with value `'1'`/`'0'` that JavaScript reads; the visual track/thumb are manipulated directly
- Password show/hide fields use Alpine `x-data` scoped to the field wrapper
- All saves use the `api()` helper (returns a Promise that rejects on non-2xx)
- Test Connection calls use raw `fetch()` (not `api()`) because the endpoint always returns HTTP 200 with a `success` flag — not a 422 on failure
- [ ] **Step 1: Replace the entire content of `resources/views/settings/integrations.blade.php` with the following**
```blade
@extends('layouts.app')
@section('title', 'Settings — Integrations')
@section('content')
<div class="mb-6">
<h1 class="page-title">Settings — Integrations</h1>
<p class="page-subtitle">Configure third-party service integrations.</p>
</div>
<div style="max-width:680px;" x-data="{ tab: 'whatsapp' }">
{{-- Pill tabs --}}
<div style="display:flex;gap:8px;margin-bottom:20px;">
<button type="button" @click="tab='whatsapp'"
:style="tab==='whatsapp' ? 'background:#1e293b;color:#fff;border:1px solid transparent;' : 'background:#fff;color:#374151;border:1px solid #d1d5db;'"
style="padding:7px 18px;border-radius:999px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;">
💬 WhatsApp
</button>
<button type="button" @click="tab='email'"
:style="tab==='email' ? 'background:#1e293b;color:#fff;border:1px solid transparent;' : 'background:#fff;color:#374151;border:1px solid #d1d5db;'"
style="padding:7px 18px;border-radius:999px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;">
✉️ Email
</button>
</div>
{{-- ===== WhatsApp tab ===== --}}
<div x-show="tab==='whatsapp'">
<div class="card">
<div style="padding:20px 24px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;gap:12px;">
<svg style="width:24px;height:24px;color:#22c55e;flex-shrink:0;" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
<h3 style="font-size:16px;font-weight:600;color:#111827;margin:0;">WhatsApp (UltraMSG)</h3>
</div>
<div style="padding:24px;">
{{-- Enable toggle --}}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<div>
<p style="font-size:14px;font-weight:500;color:#374151;margin:0 0 2px;">Enable WhatsApp Notifications</p>
<p style="font-size:12px;color:#6b7280;margin:0;">When disabled, no messages will be sent.</p>
</div>
<div style="position:relative;display:inline-flex;align-items:center;cursor:pointer;">
<input type="hidden" id="wa-enabled-hidden" value="{{ $whatsappSettings['enabled'] ? '1' : '0' }}">
<div id="wa-toggle-track" onclick="toggleWaSwitch()" style="
width:44px;height:24px;border-radius:12px;cursor:pointer;
background:{{ $whatsappSettings['enabled'] ? '#22c55e' : '#d1d5db' }};
position:relative;transition:background .2s;">
<div id="wa-toggle-thumb" style="
position:absolute;top:2px;
left:{{ $whatsappSettings['enabled'] ? '22px' : '2px' }};
width:20px;height:20px;border-radius:50%;background:#fff;
box-shadow:0 1px 3px rgba(0,0,0,.2);transition:left .2s;"></div>
</div>
</div>
</div>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0 0 20px;">
{{-- Instance ID --}}
<div style="margin-bottom:16px;">
<label class="form-label">Instance ID</label>
<input type="text" id="wa-instance-id"
value="{{ $whatsappSettings['instance_id'] }}"
placeholder="e.g. instance177593"
class="form-input">
</div>
{{-- API Token --}}
<div style="margin-bottom:16px;" x-data="{ showWaToken: false }">
<label class="form-label">API Token</label>
<div style="position:relative;">
<input :type="showWaToken ? 'text' : 'password'" id="wa-token"
value="{{ $whatsappSettings['token'] }}"
placeholder="Your UltraMSG token"
class="form-input" style="padding-right:40px;">
<button type="button" @click="showWaToken = !showWaToken"
style="position:absolute;inset-y:0;right:0;padding:0 10px;background:none;border:none;cursor:pointer;color:#9ca3af;"
onmouseover="this.style.color='#4b5563'" onmouseout="this.style.color='#9ca3af'">
<svg x-show="!showWaToken" style="width:16px;height:16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
<svg x-show="showWaToken" style="width:16px;height:16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
{{-- Webhook Secret --}}
<div style="margin-bottom:16px;" x-data="{ showWaSecret: false }">
<label class="form-label">
Webhook Secret <span style="color:#9ca3af;font-weight:400;">(optional)</span>
</label>
<div style="position:relative;">
<input :type="showWaSecret ? 'text' : 'password'" id="wa-webhook-secret"
value="{{ $whatsappSettings['webhook_secret'] }}"
placeholder="Leave empty to skip HMAC verification"
class="form-input" style="padding-right:40px;">
<button type="button" @click="showWaSecret = !showWaSecret"
style="position:absolute;inset-y:0;right:0;padding:0 10px;background:none;border:none;cursor:pointer;color:#9ca3af;"
onmouseover="this.style.color='#4b5563'" onmouseout="this.style.color='#9ca3af'">
<svg x-show="!showWaSecret" style="width:16px;height:16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
<svg x-show="showWaSecret" style="width:16px;height:16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
{{-- Webhook Path --}}
<div style="margin-bottom:24px;">
<label class="form-label">Webhook Path</label>
<div style="display:flex;align-items:stretch;">
<span style="display:inline-flex;align-items:center;padding:0 12px;font-size:13px;color:#6b7280;background:#f9fafb;border:1px solid #d1d5db;border-right:none;border-radius:6px 0 0 6px;white-space:nowrap;">{{ url('/') }}/</span>
<input type="text" id="wa-webhook-path"
value="{{ $whatsappSettings['webhook_path'] }}"
class="form-input" style="border-radius:0 6px 6px 0;flex:1;">
</div>
<p style="font-size:12px;color:#6b7280;margin-top:4px;">
Paste this full URL in your UltraMSG dashboard: <strong>{{ url('/') }}/{{ $whatsappSettings['webhook_path'] }}</strong>
</p>
</div>
{{-- Actions --}}
<div style="display:flex;align-items:center;justify-content:space-between;padding-top:16px;border-top:1px solid #f3f4f6;">
<div style="display:flex;align-items:center;gap:12px;">
<button type="button" id="btn-wa-test" onclick="testWaConnection()"
style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:#2563eb;background:none;border:none;cursor:pointer;text-decoration:underline;text-underline-offset:2px;padding:0;"
onmouseover="this.style.color='#1d4ed8'" onmouseout="this.style.color='#2563eb'">
<svg style="width:15px;height:15px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Test Connection
</button>
<span id="wa-conn-status" style="font-size:13px;display:none;"></span>
</div>
<button type="button" id="btn-wa-save" onclick="saveWhatsapp()" class="btn-primary">Save Settings</button>
</div>
</div>
</div>
{{-- Send Test Message accordion --}}
<div class="card" style="margin-top:16px;">
<button type="button" onclick="toggleWaMsg()"
style="width:100%;display:flex;align-items:center;justify-content:space-between;padding:16px 24px;background:none;border:none;cursor:pointer;text-align:left;">
<div style="display:flex;align-items:center;gap:10px;">
<svg style="width:18px;height:18px;color:#22c55e;flex-shrink:0;" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
<span style="font-size:14px;font-weight:600;color:#111827;">Send Test Message</span>
<span style="font-size:12px;color:#6b7280;">— verify the connection works end-to-end</span>
</div>
<svg id="wa-msg-chevron" style="width:16px;height:16px;color:#9ca3af;transition:transform .2s;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="wa-msg-body" style="display:none;padding:0 24px 24px;">
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0 0 20px;">
<div style="margin-bottom:14px;">
<label style="display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Phone Number</label>
<input type="text" id="wa-test-to" placeholder="+97333165444" class="form-input" style="width:100%;">
</div>
<div style="margin-bottom:16px;">
<label style="display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Message</label>
<textarea id="wa-test-body" rows="3" class="form-input" style="width:100%;resize:vertical;">Test message from SteelERP — WhatsApp integration is working!</textarea>
</div>
<div style="display:flex;align-items:center;gap:14px;">
<button type="button" id="btn-wa-send" onclick="sendWaTestMessage()"
style="display:inline-flex;align-items:center;gap:7px;padding:9px 18px;font-size:13px;font-weight:600;color:#fff;background:#22c55e;border:none;border-radius:8px;cursor:pointer;"
onmouseover="this.style.background='#16a34a'" onmouseout="this.style.background='#22c55e'">
<svg style="width:15px;height:15px;" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
Send Message
</button>
<span id="wa-send-status" style="font-size:13px;display:none;"></span>
</div>
</div>
</div>
</div>{{-- end WhatsApp tab --}}
{{-- ===== Email tab ===== --}}
<div x-show="tab==='email'" style="display:none;">
<div class="card">
<div style="padding:16px 24px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;gap:12px;">
<div style="width:32px;height:32px;background:#eff6ff;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;">✉️</div>
<div>
<div style="font-size:14px;font-weight:600;color:#111827;">Microsoft 365 (Azure Mail)</div>
<div style="font-size:12px;color:#6b7280;">Send emails via Microsoft Graph API using Azure AD</div>
</div>
</div>
<div style="padding:24px;">
{{-- Enable toggle --}}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<div>
<p style="font-size:14px;font-weight:500;color:#374151;margin:0 0 2px;">Enable Email Notifications</p>
<p style="font-size:12px;color:#6b7280;margin:0;">When disabled, no emails will be sent.</p>
</div>
<div style="position:relative;display:inline-flex;align-items:center;cursor:pointer;">
<input type="hidden" id="em-enabled-hidden" value="{{ $azureSettings['enabled'] ? '1' : '0' }}">
<div id="em-toggle-track" onclick="toggleEmSwitch()" style="
width:44px;height:24px;border-radius:12px;cursor:pointer;
background:{{ $azureSettings['enabled'] ? '#22c55e' : '#d1d5db' }};
position:relative;transition:background .2s;">
<div id="em-toggle-thumb" style="
position:absolute;top:2px;
left:{{ $azureSettings['enabled'] ? '22px' : '2px' }};
width:20px;height:20px;border-radius:50%;background:#fff;
box-shadow:0 1px 3px rgba(0,0,0,.2);transition:left .2s;"></div>
</div>
</div>
</div>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0 0 20px;">
{{-- Tenant ID --}}
<div style="margin-bottom:16px;">
<label class="form-label">Tenant ID</label>
<input type="text" id="em-tenant-id"
value="{{ $azureSettings['tenant_id'] }}"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
class="form-input">
</div>
{{-- Client ID --}}
<div style="margin-bottom:16px;">
<label class="form-label">Client ID</label>
<input type="text" id="em-client-id"
value="{{ $azureSettings['client_id'] }}"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
class="form-input">
</div>
{{-- Client Secret --}}
<div style="margin-bottom:16px;" x-data="{ showEmSecret: false }">
<label class="form-label">Client Secret</label>
<div style="position:relative;">
<input :type="showEmSecret ? 'text' : 'password'" id="em-client-secret"
value="{{ $azureSettings['client_secret'] }}"
placeholder="Your Azure AD client secret"
class="form-input" style="padding-right:40px;">
<button type="button" @click="showEmSecret = !showEmSecret"
style="position:absolute;inset-y:0;right:0;padding:0 10px;background:none;border:none;cursor:pointer;color:#9ca3af;"
onmouseover="this.style.color='#4b5563'" onmouseout="this.style.color='#9ca3af'">
<svg x-show="!showEmSecret" style="width:16px;height:16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
<svg x-show="showEmSecret" style="width:16px;height:16px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
{{-- From Address --}}
<div style="margin-bottom:24px;">
<label class="form-label">From Address</label>
<input type="text" id="em-from-address"
value="{{ $azureSettings['from_address'] }}"
placeholder="noreply@yourdomain.com"
class="form-input">
<p style="font-size:12px;color:#6b7280;margin-top:4px;">Must be a mailbox in your Microsoft 365 tenant.</p>
</div>
{{-- Actions --}}
<div style="display:flex;align-items:center;justify-content:space-between;padding-top:16px;border-top:1px solid #f3f4f6;">
<div style="display:flex;align-items:center;gap:12px;">
<button type="button" id="btn-em-test" onclick="testAzureConnection()"
style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:#2563eb;background:none;border:none;cursor:pointer;text-decoration:underline;text-underline-offset:2px;padding:0;"
onmouseover="this.style.color='#1d4ed8'" onmouseout="this.style.color='#2563eb'">
<svg style="width:15px;height:15px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Test Connection
</button>
<span id="em-conn-status" style="font-size:13px;display:none;"></span>
</div>
<button type="button" id="btn-em-save" onclick="saveAzureMail()" class="btn-primary">Save Settings</button>
</div>
</div>
</div>
{{-- Send Test Email accordion --}}
<div class="card" style="margin-top:16px;">
<button type="button" onclick="toggleEmMsg()"
style="width:100%;display:flex;align-items:center;justify-content:space-between;padding:16px 24px;background:none;border:none;cursor:pointer;text-align:left;">
<div style="display:flex;align-items:center;gap:10px;">
<span style="font-size:16px;">📧</span>
<span style="font-size:14px;font-weight:600;color:#111827;">Send Test Email</span>
<span style="font-size:12px;color:#6b7280;">— verify the connection works end-to-end</span>
</div>
<svg id="em-msg-chevron" style="width:16px;height:16px;color:#9ca3af;transition:transform .2s;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="em-msg-body" style="display:none;padding:0 24px 24px;">
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0 0 20px;">
<div style="margin-bottom:12px;">
<label style="display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">To</label>
<input type="text" id="em-test-to" placeholder="recipient@example.com" class="form-input" style="width:100%;">
</div>
<div style="margin-bottom:16px;">
<label style="display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:5px;">Subject</label>
<input type="text" id="em-test-subject" value="Test Email from SteelERP" class="form-input" style="width:100%;">
</div>
<div style="display:flex;align-items:center;gap:14px;">
<button type="button" id="btn-em-send" onclick="sendTestEmail()"
style="display:inline-flex;align-items:center;gap:7px;padding:9px 18px;font-size:13px;font-weight:600;color:#fff;background:#2563eb;border:none;border-radius:8px;cursor:pointer;"
onmouseover="this.style.background='#1d4ed8'" onmouseout="this.style.background='#2563eb'">
✉️ Send Email
</button>
<span id="em-send-status" style="font-size:13px;display:none;"></span>
</div>
</div>
</div>
</div>{{-- end Email tab --}}
</div>
<script>
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
function api(url, data) {
return fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(function(r) {
return r.json().then(function(body) {
if (!r.ok) return Promise.reject(body);
return body;
});
});
}
function toggleWaSwitch() {
var hidden = document.getElementById('wa-enabled-hidden');
var track = document.getElementById('wa-toggle-track');
var thumb = document.getElementById('wa-toggle-thumb');
var on = hidden.value === '1';
hidden.value = on ? '0' : '1';
track.style.background = on ? '#d1d5db' : '#22c55e';
thumb.style.left = on ? '2px' : '22px';
}
function toggleEmSwitch() {
var hidden = document.getElementById('em-enabled-hidden');
var track = document.getElementById('em-toggle-track');
var thumb = document.getElementById('em-toggle-thumb');
var on = hidden.value === '1';
hidden.value = on ? '0' : '1';
track.style.background = on ? '#d1d5db' : '#22c55e';
thumb.style.left = on ? '2px' : '22px';
}
var _waMsgOpen = false;
function toggleWaMsg() {
_waMsgOpen = !_waMsgOpen;
document.getElementById('wa-msg-body').style.display = _waMsgOpen ? 'block' : 'none';
document.getElementById('wa-msg-chevron').style.transform = _waMsgOpen ? 'rotate(180deg)' : '';
}
var _emMsgOpen = false;
function toggleEmMsg() {
_emMsgOpen = !_emMsgOpen;
document.getElementById('em-msg-body').style.display = _emMsgOpen ? 'block' : 'none';
document.getElementById('em-msg-chevron').style.transform = _emMsgOpen ? 'rotate(180deg)' : '';
}
function saveWhatsapp() {
var btn = document.getElementById('btn-wa-save');
btn.disabled = true; btn.style.opacity = '.6';
api('{{ route('settings.integrations.whatsapp') }}', {
enabled: document.getElementById('wa-enabled-hidden').value,
instance_id: document.getElementById('wa-instance-id').value.trim(),
token: document.getElementById('wa-token').value.trim(),
webhook_secret: document.getElementById('wa-webhook-secret').value.trim(),
webhook_path: document.getElementById('wa-webhook-path').value.trim(),
}).then(function() {
showToast('WhatsApp settings saved.', 'success');
}).catch(function(err) {
var msg = err.errors ? Object.values(err.errors)[0][0] : (err.message || 'Error saving settings.');
showToast(msg, 'error');
}).finally(function() { btn.disabled = false; btn.style.opacity = '1'; });
}
function testWaConnection() {
var statusEl = document.getElementById('wa-conn-status');
statusEl.textContent = 'Testing…'; statusEl.style.display = ''; statusEl.style.color = '#6b7280';
fetch('{{ route('settings.integrations.test-whatsapp') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json' }
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.success) {
statusEl.textContent = 'Connected ✓'; statusEl.style.color = '#16a34a';
} else {
statusEl.textContent = 'Failed: ' + (data.message || 'Unknown error'); statusEl.style.color = '#dc2626';
}
}).catch(function() { statusEl.textContent = 'Request failed.'; statusEl.style.color = '#dc2626'; });
}
function sendWaTestMessage() {
var to = document.getElementById('wa-test-to').value.trim();
var body = document.getElementById('wa-test-body').value.trim();
if (!to) { showToast('Enter a phone number.', 'warn'); return; }
if (!body) { showToast('Enter a message.', 'warn'); return; }
var btn = document.getElementById('btn-wa-send');
var statusEl = document.getElementById('wa-send-status');
btn.disabled = true; btn.style.opacity = '.6';
statusEl.textContent = 'Sending…'; statusEl.style.display = ''; statusEl.style.color = '#6b7280';
api('{{ route('settings.integrations.send-test-message') }}', { to: to, body: body })
.then(function() {
statusEl.textContent = 'Sent ✓'; statusEl.style.color = '#16a34a';
showToast('Test message sent!', 'success');
}).catch(function(err) {
statusEl.textContent = 'Failed.'; statusEl.style.color = '#dc2626';
showToast(err.message || 'Failed to send.', 'error');
}).finally(function() { btn.disabled = false; btn.style.opacity = '1'; });
}
function saveAzureMail() {
var btn = document.getElementById('btn-em-save');
btn.disabled = true; btn.style.opacity = '.6';
api('{{ route('settings.integrations.azure-mail') }}', {
enabled: document.getElementById('em-enabled-hidden').value,
tenant_id: document.getElementById('em-tenant-id').value.trim(),
client_id: document.getElementById('em-client-id').value.trim(),
client_secret: document.getElementById('em-client-secret').value.trim(),
from_address: document.getElementById('em-from-address').value.trim(),
}).then(function() {
showToast('Email settings saved.', 'success');
}).catch(function(err) {
var msg = err.errors ? Object.values(err.errors)[0][0] : (err.message || 'Error saving settings.');
showToast(msg, 'error');
}).finally(function() { btn.disabled = false; btn.style.opacity = '1'; });
}
function testAzureConnection() {
var statusEl = document.getElementById('em-conn-status');
statusEl.textContent = 'Testing…'; statusEl.style.display = ''; statusEl.style.color = '#6b7280';
fetch('{{ route('settings.integrations.test-azure-mail') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({})
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.success) {
statusEl.textContent = 'Connected ✓'; statusEl.style.color = '#16a34a';
showToast('Azure AD connection successful.', 'success');
} else {
statusEl.textContent = 'Failed: ' + (data.message || 'Unknown error'); statusEl.style.color = '#dc2626';
showToast(data.message || 'Connection failed.', 'error');
}
}).catch(function() { statusEl.textContent = 'Request failed.'; statusEl.style.color = '#dc2626'; showToast('Request failed.', 'error'); });
}
function sendTestEmail() {
var to = document.getElementById('em-test-to').value.trim();
var subject = document.getElementById('em-test-subject').value.trim();
if (!to) { showToast('Enter a recipient email.', 'warn'); return; }
if (!subject) { showToast('Enter a subject.', 'warn'); return; }
var btn = document.getElementById('btn-em-send');
var statusEl = document.getElementById('em-send-status');
btn.disabled = true; btn.style.opacity = '.6';
statusEl.textContent = 'Sending…'; statusEl.style.display = ''; statusEl.style.color = '#6b7280';
api('{{ route('settings.integrations.send-test-email') }}', { to: to, subject: subject })
.then(function() {
statusEl.textContent = 'Sent ✓'; statusEl.style.color = '#16a34a';
showToast('Test email sent!', 'success');
}).catch(function(err) {
statusEl.textContent = 'Failed.'; statusEl.style.color = '#dc2626';
showToast(err.message || 'Failed to send email.', 'error');
}).finally(function() { btn.disabled = false; btn.style.opacity = '1'; });
}
</script>
@endsection
```
- [ ] **Step 2: Verify page renders without errors**
With `php artisan serve` running, visit `http://localhost:8000/settings/integrations` as an Admin user.
Expected:
- Page loads with pill tabs "💬 WhatsApp" (active, dark) and "✉️ Email" (inactive, white/bordered)
- WhatsApp card visible with all existing fields
- Clicking "✉️ Email" tab switches to the Email panel
- Email panel shows Tenant ID, Client ID, Client Secret, From Address fields
- [ ] **Step 3: Test WhatsApp AJAX save**
Fill in the WhatsApp fields and click "Save Settings".
Expected: green toast "WhatsApp settings saved." — no page reload.
- [ ] **Step 4: Test Email tab save (validation)**
Switch to Email tab, click "Save Settings" without filling in Tenant ID.
Expected: red toast showing the validation error message.
- [ ] **Step 5: Commit**
```bash
git add resources/views/settings/integrations.blade.php
git commit -m "feat: rewrite integrations view with WhatsApp/Email pill tabs and AJAX saves"
```
---
## Verification Checklist
After all three tasks are complete:
- [ ] `php artisan route:list --name=settings.integrations` shows 7 routes
- [ ] Integrations page loads at `/settings/integrations` with pill tabs
- [ ] Tab switching works (WhatsApp ↔ Email) without page reload
- [ ] WhatsApp save uses AJAX (no page reload, toast appears)
- [ ] Email save uses AJAX (no page reload, toast appears)
- [ ] Email validation error (empty Tenant ID) shows toast with message
- [ ] Test Connection (WhatsApp) still works
- [ ] "Send Test Message" accordion still opens/closes
- [ ] Email tab fields pre-populate from DB if previously saved
@@ -0,0 +1,440 @@
# VAT Settings + RFQ Per-Item Vatable Checkbox — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a global VAT rate setting and per-item vatable checkboxes on the supplier RFQ portal, with live VAT breakdown in totals.
**Architecture:** Global VAT rate stored in existing `settings` key/value table. Per-item `is_vatable` flag added to `supplier_quote_items`. RFQ portal view updated with checkbox column and three-row footer (subtotal / VAT / grand total). New `VatSettingController` + `settings/vat` page added under the Admin-only System sidebar section.
**Tech Stack:** Laravel 12, PHP 8.2, SQLite, Blade, Alpine-free vanilla JS, Tailwind (inline styles per project convention)
---
### Task 1: Migration — add `is_vatable` to `supplier_quote_items`
**Files:**
- Modify: `database/migrations/2026_06_01_090734_add_is_vatable_to_supplier_quote_items.php`
- Modify: `app/Models/SupplierQuoteItem.php`
- [ ] **Step 1: Write the migration**
Replace the generated migration body with:
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('supplier_quote_items', function (Blueprint $table) {
$table->boolean('is_vatable')->default(false)->after('total_price');
});
}
public function down(): void
{
Schema::table('supplier_quote_items', function (Blueprint $table) {
$table->dropColumn('is_vatable');
});
}
};
```
- [ ] **Step 2: Run migration**
```bash
php artisan migrate
```
Expected: `Migrating: 2026_06_01_090734_add_is_vatable_to_supplier_quote_items` then `Migrated`.
- [ ] **Step 3: Update SupplierQuoteItem model**
In `app/Models/SupplierQuoteItem.php`, update `$fillable`:
```php
protected $fillable = [
'supplier_quote_id', 'description', 'unit', 'quantity', 'unit_price', 'total_price', 'is_vatable',
];
protected $casts = [
'is_vatable' => 'boolean',
];
```
- [ ] **Step 4: Commit**
```bash
git add database/migrations/2026_06_01_090734_add_is_vatable_to_supplier_quote_items.php app/Models/SupplierQuoteItem.php
git commit -m "feat: add is_vatable column to supplier_quote_items"
```
---
### Task 2: VatSettingController + route + view
**Files:**
- Create: `app/Http/Controllers/Settings/VatSettingController.php`
- Create: `resources/views/settings/vat.blade.php`
- Modify: `routes/web.php`
- [ ] **Step 1: Create VatSettingController**
Create `app/Http/Controllers/Settings/VatSettingController.php`:
```php
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\Request;
class VatSettingController extends Controller
{
public function index()
{
$vatRate = Setting::get('vat_rate', '0');
return view('settings.vat', compact('vatRate'));
}
public function update(Request $request)
{
$validated = $request->validate([
'vat_rate' => ['required', 'numeric', 'min:0', 'max:100'],
]);
Setting::set('vat_rate', (string) $validated['vat_rate']);
return response()->json(['message' => 'VAT rate saved.', 'vat_rate' => $validated['vat_rate']]);
}
}
```
- [ ] **Step 2: Add routes**
In `routes/web.php`, after the integrations routes (around line 148), add:
```php
// VAT settings
Route::get('settings/vat', [VatSettingController::class, 'index'])->name('settings.vat');
Route::post('settings/vat', [VatSettingController::class, 'update'])->name('settings.vat.update');
```
Also add the import at the top of the file with the other Settings controllers:
```php
use App\Http\Controllers\Settings\VatSettingController;
```
- [ ] **Step 3: Create the VAT settings view**
Create `resources/views/settings/vat.blade.php`:
```blade
@extends('layouts.app')
@section('title', 'Settings — VAT')
@section('content')
<div class="mb-5">
<h1 class="page-title">VAT Settings</h1>
<p class="page-subtitle">Set the global VAT rate applied to vatable items on supplier quotes.</p>
</div>
<div style="max-width:480px;">
<div style="background:white;border:1px solid #e2e8f0;border-radius:0.875rem;overflow:hidden;">
<div style="padding:1.25rem 1.5rem;border-bottom:1px solid #e2e8f0;background:#f8fafc;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;">VAT Configuration</div>
</div>
<div style="padding:1.5rem;">
<label style="display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:6px;">
VAT Rate (%)
</label>
<div style="display:flex;align-items:center;gap:10px;">
<input type="number" id="vat-rate-input" value="{{ $vatRate }}"
min="0" max="100" step="0.01" placeholder="e.g. 10"
style="width:160px;padding:9px 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:14px;font-weight:600;outline:none;"
onfocus="this.style.borderColor='#2563eb'" onblur="this.style.borderColor='#e2e8f0'">
<span style="font-size:14px;color:#64748b;font-weight:500;">%</span>
</div>
<p style="font-size:12px;color:#94a3b8;margin-top:8px;">
Enter 0 to disable VAT. Suppliers will see the VAT checkbox on their quote form when this is greater than 0.
</p>
<button onclick="saveVat()"
style="margin-top:20px;padding:10px 24px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;">
Save VAT Rate
</button>
</div>
</div>
</div>
<script>
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
function saveVat() {
var rate = document.getElementById('vat-rate-input').value;
fetch('{{ route('settings.vat.update') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ vat_rate: rate })
}).then(function(r) {
return r.json().then(function(body) {
if (!r.ok) return Promise.reject(body);
return body;
});
}).then(function() {
showToast('VAT rate saved.', 'success');
}).catch(function(err) {
showToast((err.errors && err.errors.vat_rate ? err.errors.vat_rate[0] : null) || 'Failed to save.', 'error');
});
}
</script>
@endsection
```
- [ ] **Step 4: Commit**
```bash
git add app/Http/Controllers/Settings/VatSettingController.php resources/views/settings/vat.blade.php routes/web.php
git commit -m "feat: add VAT settings page and controller"
```
---
### Task 3: Add VAT link to sidebar
**Files:**
- Modify: `resources/views/layouts/app.blade.php`
- [ ] **Step 1: Add sidebar link**
In `resources/views/layouts/app.blade.php`, after the Integrations `<a>` tag (around line 198, just before `@endrole`), add:
```blade
<a href="{{ route('settings.vat') }}" style="
display:flex; align-items:center; gap:8px;
padding:7px 12px 7px 24px; border-radius:7px; margin-bottom:1px;
font-size:13px; text-decoration:none;
{{ request()->routeIs('settings.vat*') ? 'background:#1e293b;color:#fff;font-weight:500;' : 'color:#94a3b8;' }}
" onmouseover="if(!this.style.color.includes('fff'))this.style.color='#e2e8f0'" onmouseout="if(!this.style.background.includes('1e293b'))this.style.color='#94a3b8'">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink:0;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/>
</svg>
VAT Settings
</a>
```
- [ ] **Step 2: Commit**
```bash
git add resources/views/layouts/app.blade.php
git commit -m "feat: add VAT Settings link to sidebar"
```
---
### Task 4: Update RfqPortalController — load VAT rate and store is_vatable
**Files:**
- Modify: `app/Http/Controllers/Purchase/RfqPortalController.php`
- [ ] **Step 1: Update show() to pass VAT rate**
Add `use App\Models\Setting;` at the top of the file.
Update the `show()` method — add before the `return view(...)` line:
```php
$vatRate = (float) Setting::get('vat_rate', 0);
```
Update the return to:
```php
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items', 'confirmCode', 'vatRate'));
```
- [ ] **Step 2: Update submit() to store is_vatable and calculate VAT-inclusive total**
Add `'items.*.is_vatable' => ['nullable', 'boolean']` to the validation rules array.
Replace the items loop and total calculation:
```php
$subtotal = 0;
$vatAmount = 0;
$vatRate = (float) Setting::get('vat_rate', 0);
foreach ($purchaseItems as $i => $item) {
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0);
$qty = (float)$item->quantity_required;
$totalPrice = round($unitPrice * $qty, 3);
$isVatable = !empty($validated['items'][$i]['is_vatable']);
$subtotal += $totalPrice;
if ($isVatable && $vatRate > 0) {
$vatAmount += round($totalPrice * $vatRate / 100, 3);
}
SupplierQuoteItem::create([
'supplier_quote_id' => $quote->id,
'description' => $item->description,
'unit' => $item->unit ?? '',
'quantity' => $qty,
'unit_price' => $unitPrice,
'total_price' => $totalPrice,
'is_vatable' => $isVatable,
]);
}
$grand = round($subtotal + $vatAmount, 3);
$quote->update(['total_amount' => $grand]);
```
- [ ] **Step 3: Commit**
```bash
git add app/Http/Controllers/Purchase/RfqPortalController.php
git commit -m "feat: load VAT rate and store is_vatable in RFQ portal"
```
---
### Task 5: Update rfq/show.blade.php — checkbox column + VAT footer + JS
**Files:**
- Modify: `resources/views/rfq/show.blade.php`
- [ ] **Step 1: Add VAT rate JS variable**
At the start of the `<script>` block, add:
```javascript
var _vatRate = {{ $vatRate }};
```
- [ ] **Step 2: Replace calcRow() and add recalcTotals()**
Replace the entire `calcRow` function and grand total logic with:
```javascript
var _totals = {};
var _vatable = {};
function calcRow(i, qty) {
var inp = document.querySelector('input[name="items[' + i + '][unit_price]"]');
var cb = document.querySelector('input[name="items[' + i + '][is_vatable]"]');
var up = parseFloat(inp ? inp.value : 0) || 0;
var tot = Math.round(up * qty * 1000) / 1000;
_totals[i] = tot;
_vatable[i] = cb ? cb.checked : false;
var el = document.getElementById('tot-' + i);
if (el) el.textContent = tot > 0 ? 'BD ' + tot.toFixed(3) : '—';
recalcTotals();
}
function recalcTotals() {
var subtotal = 0;
var vatAmount = 0;
Object.keys(_totals).forEach(function(i) {
subtotal += _totals[i];
if (_vatable[i] && _vatRate > 0) {
vatAmount += Math.round(_totals[i] * _vatRate / 100 * 1000) / 1000;
}
});
var grand = Math.round((subtotal + vatAmount) * 1000) / 1000;
var elSub = document.getElementById('subtotal-row');
var elVat = document.getElementById('vat-row');
var elGrand = document.getElementById('grand-total');
if (elSub) elSub.textContent = 'BD ' + subtotal.toFixed(3);
if (elVat) {
elVat.textContent = 'BD ' + vatAmount.toFixed(3);
var vatTr = document.getElementById('vat-tr');
if (vatTr) vatTr.style.display = _vatRate > 0 ? '' : 'none';
}
if (elGrand) elGrand.textContent = 'BD ' + grand.toFixed(3);
}
```
- [ ] **Step 3: Update table header**
Replace the `<thead>` block:
```html
<thead>
<tr>
<th>#</th>
<th>Description</th>
<th>Qty</th>
<th>Unit</th>
<th style="text-align:center;">VAT?</th>
<th style="text-align:right;">Unit Price (BD)</th>
<th style="text-align:right;">Total (BD)</th>
</tr>
</thead>
```
- [ ] **Step 4: Update each item row**
Replace the `@foreach` tbody rows:
```blade
@foreach($items as $i => $item)
<tr>
<td style="color:#94a3b8;font-size:12px;">{{ $i + 1 }}</td>
<td style="font-weight:500;">{{ $item->description }}</td>
<td>{{ rtrim(rtrim(number_format((float)$item->quantity_required, 3), '0'), '.') }}</td>
<td style="color:#64748b;">{{ $item->unit ?: '—' }}</td>
<td style="text-align:center;">
@if($vatRate > 0)
<input type="checkbox" name="items[{{ $i }}][is_vatable]" value="1"
onchange="calcRow({{ $i }}, {{ (float)$item->quantity_required }})"
style="width:16px;height:16px;accent-color:#2563eb;cursor:pointer;">
@else
<span style="color:#cbd5e1;font-size:11px;">—</span>
@endif
</td>
<td style="text-align:right;">
<input type="number" class="price" name="items[{{ $i }}][unit_price]"
min="0" step="0.001" required placeholder="0.000"
oninput="calcRow({{ $i }}, {{ (float)$item->quantity_required }})">
</td>
<td style="text-align:right;font-weight:600;" id="tot-{{ $i }}">—</td>
</tr>
@endforeach
```
- [ ] **Step 5: Update tfoot**
Replace the `<tfoot>` block:
```html
<tfoot>
<tr style="background:#f8fafc;">
<td colspan="6" style="text-align:right;font-size:13px;color:#475569;">Subtotal:</td>
<td style="text-align:right;font-size:14px;color:#475569;" id="subtotal-row">BD 0.000</td>
</tr>
<tr id="vat-tr" style="background:#fffbeb;{{ $vatRate > 0 ? '' : 'display:none;' }}">
<td colspan="6" style="text-align:right;font-size:13px;color:#92400e;">VAT ({{ $vatRate }}%):</td>
<td style="text-align:right;font-size:14px;color:#92400e;" id="vat-row">BD 0.000</td>
</tr>
<tr style="background:#f8fafc;border-top:2px solid #e2e8f0;">
<td colspan="6" style="text-align:right;font-size:13px;color:#475569;font-weight:700;">Grand Total:</td>
<td style="text-align:right;font-size:15px;color:#2563eb;font-weight:700;" id="grand-total">BD 0.000</td>
</tr>
</tfoot>
```
- [ ] **Step 6: Commit**
```bash
git add resources/views/rfq/show.blade.php
git commit -m "feat: add VAT checkbox column and live breakdown to RFQ portal"
```
@@ -0,0 +1,149 @@
# Integrations Page — Tabbed UI Design Spec
**Date:** 2026-05-26
**Status:** Approved
---
## Overview
Redesign the Settings → Integrations page to use pill-style tabs — **WhatsApp** and **Email** — so both integrations are accessible from a single page. The Email tab exposes the Microsoft 365 Azure AD credentials (stored via the `Setting` model) and provides test connection + send test email actions via AJAX.
---
## Goals
- Add pill tabs (WhatsApp | Email) at the top of the integrations page
- Keep the WhatsApp tab visually identical to the current design
- Add a new Email tab for Microsoft 365 / Azure AD Mail configuration
- Convert both tabs' save operations to AJAX (rule #11 — no page reloads on settings pages)
- Use the existing design system: `.card`, `.form-input`, `.form-label`, `.btn-primary`, `showToast()`
- All inline styles used for one-off values (Tailwind JIT rule)
---
## UI Design
### Tab bar
Pill-style tabs rendered above the card. Alpine.js `x-data="{ tab: 'whatsapp' }"` switches visibility.
- **Active tab:** `background:#1e293b; color:#fff; border-radius:999px; padding:7px 18px;`
- **Inactive tab:** `background:#fff; color:#374151; border:1px solid #d1d5db; border-radius:999px; padding:7px 18px;`
### WhatsApp tab (unchanged fields, AJAX save)
Same fields as today: Enable toggle, Instance ID, API Token (masked), Webhook Secret (optional, masked), Webhook Path. Save button → AJAX POST. Test Connection and Send Test Message accordion remain unchanged.
### Email tab (new)
Card header: envelope icon + "Microsoft 365 (Azure Mail)" title + subtitle.
Fields:
| Field | Input type | Settings key | Validation |
|-------|-----------|-------------|------------|
| Enable Email Notifications | Toggle | `azure_mail_enabled` | boolean |
| Tenant ID | text | `azure_mail_tenant_id` | required, max:100 |
| Client ID | text | `azure_mail_client_id` | required, max:100 |
| Client Secret | password (show/hide) | `azure_mail_client_secret` | required, max:500 |
| From Address | text | `azure_mail_from_address` | required, email, max:255 |
Actions:
- **Test Connection** link → AJAX POST → calls `TokenManager::getToken()` to verify Azure AD responds
- **Save Settings** button → AJAX POST → stores all 5 keys via `Setting::set()`
Send Test Email accordion (same expand/collapse pattern as WhatsApp):
- **To** field (email address)
- **Subject** field (pre-filled: "Test Email from SteelERP")
- **Send Email** button → AJAX POST → `Mail::mailer('azure')->raw(...)`
---
## Architecture
### Files changed
| File | Change |
|------|--------|
| `resources/views/settings/integrations.blade.php` | Full rewrite — pill tabs, both tab panels, AJAX JS |
| `app/Http/Controllers/SettingsController.php` | Add 3 new methods; update `integrations()` and `updateWhatsapp()` |
| `routes/web.php` | Add 3 new POST routes for azure-mail |
### Controller changes
**`integrations(): View`** — pass both `$whatsappSettings` and `$azureSettings` to view.
**`updateWhatsapp(Request $request): JsonResponse`** — same validation, save logic unchanged, but return `response()->json(['success' => true])` instead of redirect.
**`updateAzureMail(Request $request): JsonResponse`**
```
Validates: tenant_id (required, max:100), client_id (required, max:100),
client_secret (required, max:500), from_address (required, email, max:255)
Saves: azure_mail_enabled, azure_mail_tenant_id, azure_mail_client_id,
azure_mail_client_secret, azure_mail_from_address
Returns: JSON {success: true}
```
**`testAzureMailConnection(): JsonResponse`**
```
Reads azure_mail_* settings from DB
Instantiates TokenManager with those settings
Calls getToken() — success means Azure AD is reachable and credentials are valid
Returns: JSON {success: true} or {success: false, message: $e->getMessage()}
```
**`sendTestEmail(Request $request): JsonResponse`**
```
Validates: to (required, email, max:255), subject (required, max:255)
Uses Mail::mailer('azure')->raw('This is a test email from SteelERP.', fn($m) => $m->to($to)->subject($subject))
Returns: JSON {success: true} or {success: false, message: ...}
```
### New routes
```php
Route::post('settings/integrations/azure-mail', [SettingsController::class, 'updateAzureMail'])->name('settings.integrations.azure-mail');
Route::post('settings/integrations/test-azure-mail', [SettingsController::class, 'testAzureMailConnection'])->name('settings.integrations.test-azure-mail');
Route::post('settings/integrations/send-test-email', [SettingsController::class, 'sendTestEmail'])->name('settings.integrations.send-test-email');
```
---
## AJAX Pattern
Both tabs use the global `api()` helper pattern from CLAUDE.md rule #11:
```javascript
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
function api(url, data) {
return fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'Content-Type': 'application/json' }
body: JSON.stringify(data)
}).then(function(r) {
return r.json().then(function(body) {
if (!r.ok) return Promise.reject(body);
return body;
});
});
}
```
On success → `showToast('Settings saved.', 'success')`
On error → `showToast(err.message || 'Error', 'error')`
---
## Error Handling
- Laravel validation failure (422) → `Accept: application/json` header ensures JSON error response → `showToast()` with first validation error message
- Azure AD token fetch failure (`AuthenticationException`) → caught in `testAzureMailConnection`, message returned as JSON
- Mail send failure → caught, message returned as JSON
---
## Non-Goals
- No URL changes — integrations page stays at `/settings/integrations`
- No tab state persisted in URL
- No changes to WhatsApp fields or business logic
@@ -0,0 +1,133 @@
# VAT Settings + RFQ Per-Item Vatable Checkbox — Design Spec
**Date:** 2026-06-01
**Status:** Approved
---
## Overview
Two connected features:
1. **Global VAT setting** — Admin sets a VAT rate (%) once in Settings. Stored in the existing `settings` key/value table as `vat_rate`.
2. **Per-item vatable checkbox on RFQ portal** — When a supplier fills in their quote, each item row has a "VAT?" checkbox. Ticking it applies the global VAT rate to that item's total. The footer shows a live breakdown: Subtotal → VAT → Grand Total. Everything is stored against the quote on submission.
---
## 1. VAT Global Setting
### Route & Controller
- `GET settings/vat` → `VatSettingController@index` — renders the VAT settings page
- `POST settings/vat` → `VatSettingController@update` — saves the rate via AJAX, returns JSON
### Storage
- Uses the existing `Setting` model (`settings` table, key/value pairs)
- Key: `vat_rate`, value: decimal string e.g. `"10"` for 10%
- Default when not set: `0`
### View: `resources/views/settings/vat.blade.php`
- Extends `layouts.app`
- Single card with a number input: "VAT Rate (%)" — accepts decimals (e.g. 5, 10, 14.5)
- Save button submits via `fetch()` AJAX (no page reload, per project rules)
- Shows current saved value on load
- Success/error feedback via `showToast()`
### Sidebar
- Add "VAT Settings" link under the `@role('Admin')` System section in `layouts/app.blade.php`
- Active state: `request()->routeIs('settings.vat')`
---
## 2. RFQ Portal — Per-Item Vatable Checkbox
### Database
- New migration: add `is_vatable` boolean (default `false`) to `supplier_quote_items` table
- `SupplierQuoteItem::$fillable` gains `is_vatable`
### Controller: `RfqPortalController`
**`show()` method:**
- Load VAT rate: `$vatRate = (float) Setting::get('vat_rate', 0)`
- Pass `$vatRate` to `rfq.show` view
**`submit()` method:**
- Add validation rule: `'items.*.is_vatable' => ['nullable', 'boolean']`
- When creating each `SupplierQuoteItem`, set `is_vatable` from submitted checkbox value
- Calculate VAT: sum of `(unit_price × quantity)` for vatable items × `vatRate / 100`
- Store `total_amount` on `SupplierQuote` as the VAT-inclusive grand total
### View: `resources/views/rfq/show.blade.php`
**Table header** — add "VAT?" column:
```
# | Description | Qty | Unit | Unit Price (BD) | VAT? | Total (BD)
```
**Each item row** — add checkbox cell:
```html
<input type="checkbox" name="items[N][is_vatable]" value="1"
onchange="calcRow(N, qty)" style="accent-color:#2563eb;width:16px;height:16px;">
```
**Footer** — replace single "Grand Total" row with three rows:
```
Subtotal (before VAT): BD XXX.XXX
VAT (10%): BD XXX.XXX ← hidden when vatRate is 0
Grand Total: BD XXX.XXX
```
**JavaScript `calcRow()` update:**
- Each row calculates its own total (unit_price × qty), unchanged
- `recalcTotals()` called after every row change:
- Sums all row totals → subtotal
- Sums row totals for checked (vatable) rows × `vatRate/100` → vatAmount
- grand = subtotal + vatAmount
- Updates subtotal, VAT, and grand total display elements
- VAT rate injected as a JS variable: `var _vatRate = {{ $vatRate }};`
- VAT row is hidden when `_vatRate === 0`
### Mobile stacked layout
On mobile (≤640px), the table renders as cards. The VAT checkbox appears as a labelled row inside each card.
---
## 3. Data Flow Summary
```
Admin sets vat_rate = 10 in settings/vat
↓
RfqPortalController::show() reads vat_rate, passes to view
↓
Supplier ticks VAT on items 1 and 3
↓
JS: subtotal = sum(all items), vat = sum(vatable items) × 0.10, grand = subtotal + vat
↓
Supplier submits form
↓
RfqPortalController::submit():
- stores is_vatable per SupplierQuoteItem
- total_amount on SupplierQuote = subtotal + vat (VAT-inclusive)
```
---
## 4. Files Touched
| File | Change |
|------|--------|
| `routes/web.php` | Add `GET/POST settings/vat` routes |
| `app/Http/Controllers/Settings/VatSettingController.php` | New controller |
| `resources/views/settings/vat.blade.php` | New view |
| `resources/views/layouts/app.blade.php` | Add sidebar link |
| `database/migrations/..._add_is_vatable_to_supplier_quote_items.php` | New migration |
| `app/Models/SupplierQuoteItem.php` | Add `is_vatable` to `$fillable` |
| `app/Http/Controllers/Purchase/RfqPortalController.php` | Load VAT rate, store `is_vatable`, calc VAT total |
| `resources/views/rfq/show.blade.php` | Add checkbox column, VAT footer breakdown, JS update |
---
## 5. Out of Scope
- Showing VAT breakdown on the internal quote comparison view (can be added later)
- Per-supplier or per-item VAT rates (single global rate only)
- VAT registration numbers
+3
View File
@@ -0,0 +1,3 @@
/vendor/
composer.lock
*.cache
+82
View File
@@ -0,0 +1,82 @@
# promoseven/azure-mailer
Laravel mail transport for Microsoft 365 via the Azure AD Graph API.
Replaces SMTP with a Client Credentials OAuth2 flow — drop-in compatible with
Laravel's `Mail` facade, Mailables, Notifications, and queued mail.
## Requirements
- PHP 8.2+
- Laravel 11 or 12
- An Azure AD App Registration with `Mail.Send` application permission
## Installation
```bash
composer require promoseven/azure-mailer
```
## Azure AD Setup
1. Go to **Azure Portal → App Registrations → New registration**
2. Note the **Tenant ID**, **Client ID**
3. Under **Certificates & secrets**, create a new **Client secret**
4. Under **API permissions**, add **Microsoft Graph → Application permissions → Mail.Send**
5. Click **Grant admin consent**
## Configuration
Add to `config/mail.php` under `mailers`:
```php
'azure' => [
'transport' => 'azure',
'tenant_id' => env('AZURE_TENANT_ID'),
'client_id' => env('AZURE_CLIENT_ID'),
'client_secret' => env('AZURE_CLIENT_SECRET'),
'from_address' => env('AZURE_MAIL_FROM_ADDRESS'),
],
```
Set `.env`:
```env
MAIL_MAILER=azure
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret
AZURE_MAIL_FROM_ADDRESS=noreply@yourdomain.com
```
The `from_address` must be a mailbox in your Microsoft 365 tenant.
## Advanced config (optional)
Publish the config file to override defaults:
```bash
php artisan vendor:publish --tag=azure-mailer-config
```
This creates `config/azure-mailer.php`:
```php
return [
'save_to_sent_items' => false, // set true to keep copies in Sent folder
'timeout' => 30, // HTTP timeout in seconds
'graph_api_version' => 'v1.0', // or 'beta'
];
```
## Usage
No changes needed — use Laravel mail exactly as before:
```php
Mail::to('user@example.com')->send(new OrderConfirmation($order));
```
## License
MIT
+40
View File
@@ -0,0 +1,40 @@
{
"name": "promoseven/azure-mailer",
"description": "Laravel mail transport for Microsoft 365 via Azure AD Graph API",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.2",
"illuminate/support": "^11.0|^12.0",
"illuminate/http": "^11.0|^12.0",
"illuminate/cache": "^11.0|^12.0",
"illuminate/mail": "^11.0|^12.0",
"symfony/mailer": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"orchestra/testbench": "^10.0"
},
"autoload": {
"psr-4": {
"PromoSeven\\AzureMailer\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"PromoSeven\\AzureMailer\\Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": [
"PromoSeven\\AzureMailer\\AzureMailerServiceProvider"
]
}
},
"scripts": {
"test": "vendor/bin/phpunit"
},
"minimum-stability": "stable",
"prefer-stable": true
}
@@ -0,0 +1,7 @@
<?php
return [
'save_to_sent_items' => false,
'timeout' => 30,
'graph_api_version' => 'v1.0',
];
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer;
use Illuminate\Mail\MailManager;
use Illuminate\Support\ServiceProvider;
use PromoSeven\AzureMailer\Graph\GraphClient;
use PromoSeven\AzureMailer\Graph\TokenManager;
use PromoSeven\AzureMailer\Transport\AzureTransport;
class AzureMailerServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../config/azure-mailer.php', 'azure-mailer');
}
public function boot(): void
{
$this->publishes([
__DIR__ . '/../config/azure-mailer.php' => config_path('azure-mailer.php'),
], 'azure-mailer-config');
$this->callAfterResolving(MailManager::class, function (MailManager $manager) {
$manager->extend('azure', function (array $config) {
$merged = array_merge(config('azure-mailer', []), $config);
return new AzureTransport(
new GraphClient(new TokenManager($merged), $merged),
$merged
);
});
});
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Exceptions;
class AuthenticationException extends \RuntimeException
{
public static function fromResponse(string $error, string $description): self
{
return new self("Azure AD authentication failed: [{$error}] {$description}");
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Exceptions;
class GraphApiException extends \RuntimeException
{
public static function fromResponse(string $code, string $message): self
{
return new self("Graph API error: [{$code}] {$message}");
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Graph;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use PromoSeven\AzureMailer\Exceptions\GraphApiException;
class GraphClient
{
public function __construct(
private readonly TokenManager $tokenManager,
private readonly array $config
) {}
public function send(array $payload): void
{
$url = $this->endpoint();
$response = Http::withToken($this->tokenManager->getToken())
->timeout($this->config['timeout'] ?? 30)
->post($url, $payload);
if ($response->status() === 401) {
Log::warning('azure-mailer: 401 received, retrying with fresh token');
$this->tokenManager->invalidate();
$response = Http::withToken($this->tokenManager->getToken())
->timeout($this->config['timeout'] ?? 30)
->post($url, $payload);
}
if (! $response->successful()) {
$error = $response->json('error', []);
throw GraphApiException::fromResponse(
$error['code'] ?? (string) $response->status(),
$error['message'] ?? 'Unknown error'
);
}
}
private function endpoint(): string
{
$version = $this->config['graph_api_version'] ?? 'v1.0';
$from = $this->config['from_address'];
return "https://graph.microsoft.com/{$version}/users/{$from}/sendMail";
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Graph;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use PromoSeven\AzureMailer\Exceptions\AuthenticationException;
class TokenManager
{
public function __construct(private readonly array $config) {}
public function getToken(): string
{
$key = $this->cacheKey();
if ($token = Cache::get($key)) {
return $token;
}
return $this->fetchAndCache($key);
}
public function invalidate(): void
{
Cache::forget($this->cacheKey());
}
private function fetchAndCache(string $key): string
{
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->config['tenant_id']}/oauth2/v2.0/token",
[
'grant_type' => 'client_credentials',
'client_id' => $this->config['client_id'],
'client_secret' => $this->config['client_secret'],
'scope' => 'https://graph.microsoft.com/.default',
]
);
if (! $response->successful()) {
$body = $response->json();
throw AuthenticationException::fromResponse(
$body['error'] ?? 'unknown_error',
$body['error_description'] ?? 'No description provided'
);
}
$body = $response->json();
$ttl = max(1, ($body['expires_in'] ?? 3600) - 60);
Cache::put($key, $body['access_token'], $ttl);
return $body['access_token'];
}
private function cacheKey(): string
{
return 'azure_mailer_token_' . $this->config['client_id'];
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Transport;
use PromoSeven\AzureMailer\Graph\GraphClient;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Part\DataPart;
class AzureTransport extends AbstractTransport
{
public function __construct(
private readonly GraphClient $client,
private readonly array $config = []
) {
parent::__construct();
}
protected function doSend(SentMessage $message): void
{
$email = $message->getOriginalMessage();
if (! $email instanceof Email) {
return;
}
$this->client->send($this->buildPayload($email));
}
public function __toString(): string
{
return 'azure';
}
private function buildPayload(Email $email): array
{
return [
'message' => [
'subject' => $email->getSubject() ?? '',
'body' => [
'contentType' => $email->getHtmlBody() !== null ? 'HTML' : 'Text',
'content' => $email->getHtmlBody() ?? $email->getTextBody() ?? '',
],
'toRecipients' => $this->mapAddresses($email->getTo()),
'ccRecipients' => $this->mapAddresses($email->getCc()),
'bccRecipients' => $this->mapAddresses($email->getBcc()),
'replyTo' => $this->mapAddresses($email->getReplyTo()),
'attachments' => $this->mapAttachments($email),
],
'saveToSentItems' => (bool) ($this->config['save_to_sent_items'] ?? false),
];
}
private function mapAddresses(array $addresses): array
{
return array_map(fn ($addr) => [
'emailAddress' => [
'address' => $addr->getAddress(),
'name' => $addr->getName() ?? '',
],
], $addresses);
}
private function mapAttachments(Email $email): array
{
$result = [];
foreach ($email->getAttachments() as $attachment) {
if (! $attachment instanceof DataPart) {
continue;
}
$result[] = [
'@odata.type' => '#microsoft.graph.fileAttachment',
'name' => $attachment->getFilename() ?? 'attachment',
'contentType' => $attachment->getMediaType() . '/' . $attachment->getMediaSubtype(),
'contentBytes' => base64_encode($attachment->getBody()),
];
}
return $result;
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Tests;
use PromoSeven\AzureMailer\Exceptions\AuthenticationException;
use PromoSeven\AzureMailer\Exceptions\GraphApiException;
class ExceptionsTest extends \PHPUnit\Framework\TestCase
{
public function test_authentication_exception_formats_message(): void
{
$e = AuthenticationException::fromResponse('invalid_client', 'The client secret is incorrect.');
$this->assertInstanceOf(\RuntimeException::class, $e);
$this->assertStringContainsString('invalid_client', $e->getMessage());
$this->assertStringContainsString('The client secret is incorrect.', $e->getMessage());
}
public function test_graph_api_exception_formats_message(): void
{
$e = GraphApiException::fromResponse('ErrorItemNotFound', 'The specified object was not found.');
$this->assertInstanceOf(\RuntimeException::class, $e);
$this->assertStringContainsString('ErrorItemNotFound', $e->getMessage());
$this->assertStringContainsString('The specified object was not found.', $e->getMessage());
}
}
Whitespace-only changes.
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Tests\Graph;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use PromoSeven\AzureMailer\Exceptions\GraphApiException;
use PromoSeven\AzureMailer\Graph\GraphClient;
use PromoSeven\AzureMailer\Graph\TokenManager;
use PromoSeven\AzureMailer\Tests\TestCase;
class GraphClientTest extends TestCase
{
private array $config = [
'tenant_id' => 'test-tenant',
'client_id' => 'test-client-id',
'client_secret' => 'test-secret',
'from_address' => '[email protected]',
'timeout' => 30,
'graph_api_version' => 'v1.0',
];
private array $payload = [
'message' => [
'subject' => 'Test',
'body' => ['contentType' => 'HTML', 'content' => '<p>Hello</p>'],
'toRecipients' => [['emailAddress' => ['address' => '[email protected]', 'name' => '']]],
'ccRecipients' => [],
'bccRecipients' => [],
'replyTo' => [],
'attachments' => [],
],
'saveToSentItems' => false,
];
public function test_sends_payload_with_bearer_token(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200),
'graph.microsoft.com/*' => Http::response('', 202),
]);
$client = new GraphClient(new TokenManager($this->config), $this->config);
$client->send($this->payload);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'graph.microsoft.com/v1.0/users/[email protected]/sendMail')
&& $request->hasHeader('Authorization', 'Bearer tok');
});
}
public function test_retries_with_fresh_token_on_401(): void
{
Log::shouldReceive('warning')
->once()
->with('azure-mailer: 401 received, retrying with fresh token');
Http::fake([
'login.microsoftonline.com/*' => Http::sequence()
->push(['access_token' => 'stale-token', 'expires_in' => 3600], 200)
->push(['access_token' => 'fresh-token', 'expires_in' => 3600], 200),
'graph.microsoft.com/*' => Http::sequence()
->push('', 401)
->push('', 202),
]);
$client = new GraphClient(new TokenManager($this->config), $this->config);
$client->send($this->payload);
Http::assertSentCount(4); // 2 token fetches + 2 graph calls
}
public function test_throws_graph_api_exception_on_second_401(): void
{
Log::shouldReceive('warning')->once();
Http::fake([
'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200),
'graph.microsoft.com/*' => Http::response([
'error' => ['code' => 'InvalidAuthenticationToken', 'message' => 'Token is expired.'],
], 401),
]);
$this->expectException(GraphApiException::class);
$this->expectExceptionMessage('InvalidAuthenticationToken');
$client = new GraphClient(new TokenManager($this->config), $this->config);
$client->send($this->payload);
}
public function test_throws_graph_api_exception_on_other_error(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200),
'graph.microsoft.com/*' => Http::response([
'error' => ['code' => 'ErrorInvalidRecipients', 'message' => 'Recipient address is invalid.'],
], 400),
]);
$this->expectException(GraphApiException::class);
$this->expectExceptionMessage('ErrorInvalidRecipients');
$client = new GraphClient(new TokenManager($this->config), $this->config);
$client->send($this->payload);
}
public function test_uses_graph_api_version_from_config(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200),
'graph.microsoft.com/*' => Http::response('', 202),
]);
$config = array_merge($this->config, ['graph_api_version' => 'beta']);
$client = new GraphClient(new TokenManager($config), $config);
$client->send($this->payload);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'graph.microsoft.com/beta/');
});
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Tests\Graph;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use PromoSeven\AzureMailer\Exceptions\AuthenticationException;
use PromoSeven\AzureMailer\Graph\TokenManager;
use PromoSeven\AzureMailer\Tests\TestCase;
class TokenManagerTest extends TestCase
{
private array $config = [
'tenant_id' => 'test-tenant',
'client_id' => 'test-client-id',
'client_secret' => 'test-secret',
];
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
public function test_fetches_token_from_azure_on_cache_miss(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response([
'access_token' => 'my-access-token',
'expires_in' => 3600,
'token_type' => 'Bearer',
], 200),
]);
$manager = new TokenManager($this->config);
$token = $manager->getToken();
$this->assertSame('my-access-token', $token);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'test-tenant/oauth2/v2.0/token')
&& $request['grant_type'] === 'client_credentials'
&& $request['client_id'] === 'test-client-id'
&& $request['client_secret'] === 'test-secret'
&& $request['scope'] === 'https://graph.microsoft.com/.default';
});
}
public function test_returns_cached_token_without_hitting_azure(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response([
'access_token' => 'first-token',
'expires_in' => 3600,
], 200),
]);
$manager = new TokenManager($this->config);
$manager->getToken(); // first call — hits Azure
$manager->getToken(); // second call — should use cache
Http::assertSentCount(1);
}
public function test_invalidate_clears_cached_token(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::sequence()
->push(['access_token' => 'token-one', 'expires_in' => 3600], 200)
->push(['access_token' => 'token-two', 'expires_in' => 3600], 200),
]);
$manager = new TokenManager($this->config);
$manager->getToken(); // fetches token-one
$manager->invalidate(); // clears cache
$second = $manager->getToken(); // fetches token-two
$this->assertSame('token-two', $second);
Http::assertSentCount(2);
}
public function test_token_is_cached_with_ttl_of_expires_in_minus_60(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response([
'access_token' => 'ttl-token',
'expires_in' => 120, // 120 - 60 = 60 second TTL
], 200),
]);
$manager = new TokenManager($this->config);
$manager->getToken();
// Token should be in cache immediately after fetch
$this->assertTrue(Cache::has('azure_mailer_token_test-client-id'));
// Advance time past the TTL (61 seconds)
$this->travel(61)->seconds();
// Token should now be expired from cache
$this->assertFalse(Cache::has('azure_mailer_token_test-client-id'));
}
public function test_throws_authentication_exception_on_azure_error(): void
{
Http::fake([
'login.microsoftonline.com/*' => Http::response([
'error' => 'invalid_client',
'error_description' => 'The client secret supplied is incorrect.',
], 401),
]);
$this->expectException(AuthenticationException::class);
$this->expectExceptionMessage('invalid_client');
(new TokenManager($this->config))->getToken();
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Tests;
use Illuminate\Mail\MailManager;
use PromoSeven\AzureMailer\Transport\AzureTransport;
class ServiceProviderTest extends TestCase
{
protected function defineEnvironment($app): void
{
parent::defineEnvironment($app);
$app['config']->set('mail.mailers.azure', [
'transport' => 'azure',
'tenant_id' => 'test-tenant',
'client_id' => 'test-client-id',
'client_secret' => 'test-secret',
'from_address' => '[email protected]',
]);
}
public function test_azure_transport_is_registered_with_mail_manager(): void
{
$manager = $this->app->make(MailManager::class);
$transport = $manager->mailer('azure')->getSymfonyTransport();
$this->assertInstanceOf(AzureTransport::class, $transport);
$this->assertSame('azure', (string) $transport);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Tests;
use Orchestra\Testbench\TestCase as OrchestraTestCase;
use PromoSeven\AzureMailer\AzureMailerServiceProvider;
abstract class TestCase extends OrchestraTestCase
{
protected function getPackageProviders($app): array
{
return [AzureMailerServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('cache.default', 'array');
}
}
Whitespace-only changes.
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace PromoSeven\AzureMailer\Tests\Transport;
use PromoSeven\AzureMailer\Graph\GraphClient;
use PromoSeven\AzureMailer\Tests\TestCase;
use PromoSeven\AzureMailer\Transport\AzureTransport;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mime\Email;
class AzureTransportTest extends TestCase
{
public function test_sends_html_body(): void
{
$captured = [];
$client = $this->createMock(GraphClient::class);
$client->expects($this->once())
->method('send')
->willReturnCallback(function (array $payload) use (&$captured) {
$captured = $payload;
});
$email = (new Email())
->from('[email protected]')
->to('[email protected]')
->subject('Hello')
->html('<p>World</p>');
$transport = new AzureTransport($client, ['save_to_sent_items' => false]);
$transport->send($email, Envelope::create($email));
$this->assertSame('Hello', $captured['message']['subject']);
$this->assertSame('HTML', $captured['message']['body']['contentType']);
$this->assertSame('<p>World</p>', $captured['message']['body']['content']);
$this->assertSame('[email protected]', $captured['message']['toRecipients'][0]['emailAddress']['address']);
$this->assertFalse($captured['saveToSentItems']);
}
public function test_falls_back_to_text_body_when_no_html(): void
{
$captured = [];
$client = $this->createMock(GraphClient::class);
$client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; });
$email = (new Email())
->from('[email protected]')
->to('[email protected]')
->subject('Text only')
->text('Plain text content');
$transport = new AzureTransport($client, ['save_to_sent_items' => false]);
$transport->send($email, Envelope::create($email));
$this->assertSame('Text', $captured['message']['body']['contentType']);
$this->assertSame('Plain text content', $captured['message']['body']['content']);
}
public function test_maps_cc_bcc_and_reply_to(): void
{
$captured = [];
$client = $this->createMock(GraphClient::class);
$client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; });
$email = (new Email())
->from('[email protected]')
->to('[email protected]')
->cc('[email protected]')
->bcc('[email protected]')
->replyTo('[email protected]')
->subject('Recipients test')
->html('<p>hi</p>');
$transport = new AzureTransport($client, ['save_to_sent_items' => false]);
$transport->send($email, Envelope::create($email));
$this->assertSame('[email protected]', $captured['message']['ccRecipients'][0]['emailAddress']['address']);
$this->assertSame('[email protected]', $captured['message']['bccRecipients'][0]['emailAddress']['address']);
$this->assertSame('[email protected]', $captured['message']['replyTo'][0]['emailAddress']['address']);
}
public function test_encodes_attachments_as_base64(): void
{
$captured = [];
$client = $this->createMock(GraphClient::class);
$client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; });
$email = (new Email())
->from('[email protected]')
->to('[email protected]')
->subject('With attachment')
->html('<p>see attachment</p>')
->attach('PDF content here', 'report.pdf', 'application/pdf');
$transport = new AzureTransport($client, ['save_to_sent_items' => false]);
$transport->send($email, Envelope::create($email));
$attachment = $captured['message']['attachments'][0];
$this->assertSame('#microsoft.graph.fileAttachment', $attachment['@odata.type']);
$this->assertSame('report.pdf', $attachment['name']);
$this->assertSame(base64_encode('PDF content here'), $attachment['contentBytes']);
$this->assertSame('application/pdf', $attachment['contentType']);
}
public function test_save_to_sent_items_is_configurable(): void
{
$captured = [];
$client = $this->createMock(GraphClient::class);
$client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; });
$email = (new Email())
->from('[email protected]')
->to('[email protected]')
->subject('Save it')
->html('<p>keep</p>');
$transport = new AzureTransport($client, ['save_to_sent_items' => true]);
$transport->send($email, Envelope::create($email));
$this->assertTrue($captured['saveToSentItems']);
}
public function test_to_string_returns_azure(): void
{
$client = $this->createMock(GraphClient::class);
$transport = new AzureTransport($client, []);
$this->assertSame('azure', (string) $transport);
}
}
+2
View File
@@ -0,0 +1,2 @@
/vendor/
composer.lock
+42
View File
@@ -0,0 +1,42 @@
{
"name": "promoseven/ultra-message",
"description": "Laravel WhatsApp integration via UltraMSG API",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.2",
"illuminate/support": "^11.0|^12.0",
"illuminate/http": "^11.0|^12.0",
"illuminate/notifications": "^11.0|^12.0",
"illuminate/routing": "^11.0|^12.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"orchestra/testbench": "^10.0"
},
"autoload": {
"psr-4": {
"PromoSeven\\UltraMessage\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"PromoSeven\\UltraMessage\\Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": [
"PromoSeven\\UltraMessage\\UltraMessageServiceProvider"
],
"aliases": {
"UltraMessage": "PromoSeven\\UltraMessage\\Facades\\UltraMessage"
}
}
},
"scripts": {
"test": "vendor/bin/phpunit"
},
"minimum-stability": "stable",
"prefer-stable": true
}
@@ -0,0 +1,10 @@
<?php
return [
'instance_id' => env('ULTRAMSG_INSTANCE_ID'),
'token' => env('ULTRAMSG_TOKEN'),
'webhook_secret' => env('ULTRAMSG_WEBHOOK_SECRET', null),
'webhook_path' => env('ULTRAMSG_WEBHOOK_PATH', 'ultra-message/webhook'),
'timeout' => env('ULTRAMSG_TIMEOUT', 30),
'enabled' => env('ULTRAMSG_ENABLED', true),
];
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
use PromoSeven\UltraMessage\Http\Controllers\WebhookController;
Route::post(config('ultra-message.webhook_path', 'ultra-message/webhook'), [WebhookController::class, 'handle'])
->name('ultra-message.webhook');
@@ -0,0 +1,13 @@
<?php
namespace PromoSeven\UltraMessage\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UltraMessageWebhookReceived
{
use Dispatchable, SerializesModels;
public function __construct(public readonly array $payload) {}
}
@@ -0,0 +1,47 @@
<?php
namespace PromoSeven\UltraMessage\Facades;
use Illuminate\Support\Facades\Facade;
use PromoSeven\UltraMessage\UltraMessageClient;
use PromoSeven\UltraMessage\UltraMessageFake;
/**
* @method static array sendText(string $to, string $message, ?string $replyId = null)
* @method static array sendImage(string $to, string $imageUrl, string $caption = '')
* @method static array sendDocument(string $to, string $fileUrl, string $filename, string $caption = '')
* @method static array sendAudio(string $to, string $audioUrl)
* @method static array sendVoice(string $to, string $audioUrl)
* @method static array sendVideo(string $to, string $videoUrl, string $caption = '')
* @method static array sendSticker(string $to, string $stickerUrl)
* @method static array sendContact(string $to, string $contactId)
* @method static array sendLocation(string $to, float $lat, float $lng, string $address = '')
* @method static array sendReaction(string $to, string $messageId, string $emoji)
* @method static array deleteMessage(string $messageId)
* @method static array getInstanceStatus()
* @method static array getChats()
* @method static array getContacts()
* @method static array getGroups()
*
* @see \PromoSeven\UltraMessage\UltraMessageClient
*/
class UltraMessage extends Facade
{
protected static function getFacadeAccessor(): string
{
return UltraMessageClient::class;
}
public static function fake(): UltraMessageFake
{
$fake = new UltraMessageFake();
static::swap($fake);
return $fake;
}
public static function configUsing(callable $resolver): void
{
app()->instance('ultra-message.config-resolver', $resolver);
app()->forgetInstance(UltraMessageClient::class);
}
}
@@ -0,0 +1 @@
@@ -0,0 +1,29 @@
<?php
namespace PromoSeven\UltraMessage\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use PromoSeven\UltraMessage\Events\UltraMessageWebhookReceived;
class WebhookController extends Controller
{
public function handle(Request $request): Response
{
$secret = config('ultra-message.webhook_secret');
if ($secret) {
$signature = $request->header('X-Hub-Signature-256', '');
$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret);
if (!hash_equals($expected, $signature)) {
abort(403, 'Invalid webhook signature.');
}
}
event(new UltraMessageWebhookReceived($request->all()));
return response('OK', 200);
}
}
@@ -0,0 +1,39 @@
<?php
namespace PromoSeven\UltraMessage;
use Illuminate\Notifications\Notification;
class UltraMessageChannel
{
public function __construct(private UltraMessageClient $client) {}
public function send(mixed $notifiable, Notification $notification): void
{
if (!method_exists($notification, 'toUltraMessage')) {
return;
}
/** @var UltraMessageMessage $message */
$message = $notification->toUltraMessage($notifiable);
$to = $message->to ?: $notifiable->routeNotificationFor('ultra_message', $notification);
if (!$to) {
return;
}
match ($message->type) {
'text' => $this->client->sendText($to, $message->payload['body'], $message->payload['quoted_id'] ?? null),
'image' => $this->client->sendImage($to, $message->payload['image'], $message->payload['caption'] ?? ''),
'document' => $this->client->sendDocument($to, $message->payload['document'], $message->payload['filename'], $message->payload['caption'] ?? ''),
'audio' => $this->client->sendAudio($to, $message->payload['audio']),
'voice' => $this->client->sendVoice($to, $message->payload['audio']),
'video' => $this->client->sendVideo($to, $message->payload['video'], $message->payload['caption'] ?? ''),
'sticker' => $this->client->sendSticker($to, $message->payload['sticker']),
'contact' => $this->client->sendContact($to, $message->payload['contact']),
'location' => $this->client->sendLocation($to, $message->payload['lat'], $message->payload['lng'], $message->payload['address'] ?? ''),
default => throw new UltraMessageException("Unknown message type: {$message->type}"),
};
}
}
@@ -0,0 +1,187 @@
<?php
namespace PromoSeven\UltraMessage;
use Illuminate\Support\Facades\Http;
class UltraMessageClient
{
private const BASE_URL = 'https://api.ultramsg.com';
private string $instanceId;
private string $token;
private int $timeout;
private bool $enabled;
public function __construct(array $config)
{
$this->instanceId = $config['instance_id'] ?? '';
$this->token = $config['token'] ?? '';
$this->timeout = $config['timeout'] ?? 30;
$this->enabled = $config['enabled'] ?? true;
}
protected function post(string $endpoint, array $data): array
{
if (!$this->enabled) {
return [];
}
$response = Http::timeout($this->timeout)
->asForm()
->post(self::BASE_URL . "/{$this->instanceId}/{$endpoint}", array_merge($data, [
'token' => $this->token,
]));
if ($response->failed()) {
throw new UltraMessageException("UltraMSG HTTP error: {$response->status()}");
}
$body = $response->json() ?? [];
if (isset($body['error'])) {
throw new UltraMessageException($body['error']);
}
return $body;
}
protected function get(string $endpoint, array $query = []): array
{
if (!$this->enabled) {
return [];
}
$response = Http::timeout($this->timeout)
->get(self::BASE_URL . "/{$this->instanceId}/{$endpoint}", array_merge($query, [
'token' => $this->token,
]));
if ($response->failed()) {
throw new UltraMessageException("UltraMSG HTTP error: {$response->status()}");
}
$body = $response->json() ?? [];
if (isset($body['error'])) {
throw new UltraMessageException($body['error']);
}
return $body;
}
public function sendText(string $to, string $message, ?string $replyId = null): array
{
$data = ['to' => $to, 'body' => $message];
if ($replyId !== null) {
$data['quoted_id'] = $replyId;
}
return $this->post('messages/chat', $data);
}
public function sendImage(string $to, string $imageUrl, string $caption = ''): array
{
return $this->post('messages/image', [
'to' => $to,
'image' => $imageUrl,
'caption' => $caption,
]);
}
public function sendDocument(string $to, string $fileUrl, string $filename, string $caption = ''): array
{
return $this->post('messages/document', [
'to' => $to,
'document' => $fileUrl,
'filename' => $filename,
'caption' => $caption,
]);
}
public function sendAudio(string $to, string $audioUrl): array
{
return $this->post('messages/audio', [
'to' => $to,
'audio' => $audioUrl,
]);
}
public function sendVoice(string $to, string $audioUrl): array
{
return $this->post('messages/voice', [
'to' => $to,
'audio' => $audioUrl,
]);
}
public function sendVideo(string $to, string $videoUrl, string $caption = ''): array
{
return $this->post('messages/video', [
'to' => $to,
'video' => $videoUrl,
'caption' => $caption,
]);
}
public function sendSticker(string $to, string $stickerUrl): array
{
return $this->post('messages/sticker', [
'to' => $to,
'sticker' => $stickerUrl,
]);
}
public function sendContact(string $to, string $contactId): array
{
return $this->post('messages/contact', [
'to' => $to,
'contact' => $contactId,
]);
}
public function sendLocation(string $to, float $lat, float $lng, string $address = ''): array
{
return $this->post('messages/location', [
'to' => $to,
'lat' => $lat,
'lng' => $lng,
'address' => $address,
]);
}
public function sendReaction(string $to, string $messageId, string $emoji): array
{
return $this->post('messages/reaction', [
'to' => $to,
'msgId' => $messageId,
'emoji' => $emoji,
]);
}
public function deleteMessage(string $messageId): array
{
return $this->post('messages/delete', [
'msgId' => $messageId,
]);
}
public function getInstanceStatus(): array
{
return $this->get('instance/status');
}
public function getChats(): array
{
return $this->get('chats/');
}
public function getContacts(): array
{
return $this->get('contacts/');
}
public function getGroups(): array
{
return $this->get('groups/');
}
}
@@ -0,0 +1,7 @@
<?php
namespace PromoSeven\UltraMessage;
use RuntimeException;
class UltraMessageException extends RuntimeException {}
@@ -0,0 +1,49 @@
<?php
namespace PromoSeven\UltraMessage;
use PHPUnit\Framework\Assert;
class UltraMessageFake extends UltraMessageClient
{
private array $sent = [];
public function __construct()
{
// Skip parent constructor — no HTTP config needed in fake mode
}
protected function post(string $endpoint, array $data): array
{
$this->sent[] = ['endpoint' => $endpoint, 'data' => $data];
return ['sent' => 'ok'];
}
protected function get(string $endpoint, array $query = []): array
{
return [];
}
public function assertSent(callable $callback): void
{
Assert::assertTrue(
collect($this->sent)->contains($callback),
'Expected UltraMessage was not sent.'
);
}
public function assertNotSent(): void
{
Assert::assertEmpty($this->sent, 'Unexpected UltraMessage messages were sent.');
}
public function assertSentCount(int $count): void
{
Assert::assertCount($count, $this->sent, "Expected {$count} messages sent, got " . count($this->sent));
}
public function getSent(): array
{
return $this->sent;
}
}
@@ -0,0 +1,67 @@
<?php
namespace PromoSeven\UltraMessage;
class UltraMessageMessage
{
public string $type;
public string $to = '';
public array $payload = [];
private function __construct(string $type, array $payload)
{
$this->type = $type;
$this->payload = $payload;
}
public static function text(string $message, ?string $replyId = null): self
{
return new self('text', ['body' => $message, 'quoted_id' => $replyId]);
}
public static function image(string $url, string $caption = ''): self
{
return new self('image', ['image' => $url, 'caption' => $caption]);
}
public static function document(string $url, string $filename, string $caption = ''): self
{
return new self('document', ['document' => $url, 'filename' => $filename, 'caption' => $caption]);
}
public static function audio(string $url): self
{
return new self('audio', ['audio' => $url]);
}
public static function voice(string $url): self
{
return new self('voice', ['audio' => $url]);
}
public static function video(string $url, string $caption = ''): self
{
return new self('video', ['video' => $url, 'caption' => $caption]);
}
public static function sticker(string $url): self
{
return new self('sticker', ['sticker' => $url]);
}
public static function contact(string $contactId): self
{
return new self('contact', ['contact' => $contactId]);
}
public static function location(float $lat, float $lng, string $address = ''): self
{
return new self('location', ['lat' => $lat, 'lng' => $lng, 'address' => $address]);
}
public function to(string $number): self
{
$this->to = $number;
return $this;
}
}
@@ -0,0 +1,37 @@
<?php
namespace PromoSeven\UltraMessage;
use Illuminate\Support\ServiceProvider;
class UltraMessageServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../config/ultra-message.php', 'ultra-message');
$this->app->singleton('ultra-message.config-resolver', fn() => null);
$this->app->bind(UltraMessageClient::class, function ($app) {
$resolver = $app->make('ultra-message.config-resolver');
$config = $resolver ? call_user_func($resolver) : config('ultra-message');
return new UltraMessageClient($config);
});
$this->app->bind(UltraMessageChannel::class, function ($app) {
return new UltraMessageChannel($app->make(UltraMessageClient::class));
});
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../config/ultra-message.php' => config_path('ultra-message.php'),
], 'ultra-message-config');
}
$this->loadRoutesFrom(__DIR__ . '/../routes/webhook.php');
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace PromoSeven\UltraMessage\Tests;
use Orchestra\Testbench\TestCase as BaseTestCase;
use PromoSeven\UltraMessage\UltraMessageServiceProvider;
abstract class TestCase extends BaseTestCase
{
protected function getPackageProviders($app): array
{
return [UltraMessageServiceProvider::class];
}
protected function getEnvironmentSetUp($app): void
{
$app['config']->set('ultra-message.instance_id', 'instance123');
$app['config']->set('ultra-message.token', 'test-token');
$app['config']->set('ultra-message.enabled', true);
}
}
@@ -0,0 +1,110 @@
<?php
namespace PromoSeven\UltraMessage\Tests;
use Illuminate\Support\Facades\Http;
use Illuminate\Notifications\Notification;
use PromoSeven\UltraMessage\UltraMessageChannel;
use PromoSeven\UltraMessage\UltraMessageClient;
use PromoSeven\UltraMessage\UltraMessageMessage;
class UltraMessageChannelTest extends TestCase
{
public function test_channel_calls_send_text_for_text_message(): void
{
Http::fake(['api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200)]);
$client = new UltraMessageClient([
'instance_id' => 'instance123',
'token' => 'test-token',
'timeout' => 30,
'enabled' => true,
]);
$channel = new UltraMessageChannel($client);
$notifiable = new class {
public string $whatsapp_number = '+971501234567';
public function routeNotificationFor(string $channel, $notification = null): string
{
return $this->whatsapp_number;
}
};
$notification = new class extends Notification {
public function toUltraMessage($notifiable): UltraMessageMessage
{
return UltraMessageMessage::text('Test message');
}
};
$channel->send($notifiable, $notification);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'messages/chat')
&& $request['body'] === 'Test message'
&& $request['to'] === '+971501234567';
});
}
public function test_channel_uses_message_to_over_notifiable_route(): void
{
Http::fake(['api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200)]);
$client = new UltraMessageClient([
'instance_id' => 'instance123',
'token' => 'test-token',
'timeout' => 30,
'enabled' => true,
]);
$channel = new UltraMessageChannel($client);
$notifiable = new class {
public function routeNotificationFor(string $channel, $notification = null): string
{
return '+9710000000';
}
};
$notification = new class extends Notification {
public function toUltraMessage($notifiable): UltraMessageMessage
{
return UltraMessageMessage::text('Override test')->to('+971999999');
}
};
$channel->send($notifiable, $notification);
Http::assertSent(fn($r) => $r['to'] === '+971999999');
}
public function test_channel_skips_when_no_recipient(): void
{
Http::fake();
$client = new UltraMessageClient([
'instance_id' => 'instance123',
'token' => 'test-token',
'timeout' => 30,
'enabled' => true,
]);
$channel = new UltraMessageChannel($client);
$notifiable = new class {
public function routeNotificationFor(string $channel, $notification = null): ?string
{
return null;
}
};
$notification = new class extends Notification {
public function toUltraMessage($notifiable): UltraMessageMessage
{
return UltraMessageMessage::text('No recipient');
}
};
$channel->send($notifiable, $notification);
Http::assertNothingSent();
}
}
@@ -0,0 +1,128 @@
<?php
namespace PromoSeven\UltraMessage\Tests;
use Illuminate\Support\Facades\Http;
use PromoSeven\UltraMessage\UltraMessageClient;
use PromoSeven\UltraMessage\UltraMessageException;
class UltraMessageClientTest extends TestCase
{
private UltraMessageClient $client;
protected function setUp(): void
{
parent::setUp();
$this->client = new UltraMessageClient([
'instance_id' => 'instance123',
'token' => 'test-token',
'timeout' => 30,
'enabled' => true,
]);
}
public function test_send_text_posts_correct_payload(): void
{
Http::fake([
'api.ultramsg.com/*' => Http::response(['sent' => 'true', 'id' => 'msg1'], 200),
]);
$result = $this->client->sendText('+971501234567', 'Hello World');
Http::assertSent(function ($request) {
return str_contains($request->url(), 'instance123/messages/chat')
&& $request['to'] === '+971501234567'
&& $request['body'] === 'Hello World'
&& $request['token'] === 'test-token';
});
$this->assertEquals(['sent' => 'true', 'id' => 'msg1'], $result);
}
public function test_send_text_throws_on_api_error(): void
{
Http::fake([
'api.ultramsg.com/*' => Http::response(['error' => 'invalid token'], 200),
]);
$this->expectException(UltraMessageException::class);
$this->expectExceptionMessage('invalid token');
$this->client->sendText('+971501234567', 'Hello');
}
public function test_send_text_throws_on_http_failure(): void
{
Http::fake([
'api.ultramsg.com/*' => Http::response([], 500),
]);
$this->expectException(UltraMessageException::class);
$this->client->sendText('+971501234567', 'Hello');
}
public function test_send_returns_early_when_disabled(): void
{
Http::fake();
$client = new UltraMessageClient([
'instance_id' => 'instance123',
'token' => 'test-token',
'timeout' => 30,
'enabled' => false,
]);
$result = $client->sendText('+971501234567', 'Hello');
Http::assertNothingSent();
$this->assertEquals([], $result);
}
public function test_send_image_posts_correct_payload(): void
{
Http::fake([
'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200),
]);
$this->client->sendImage('+971501234567', 'https://example.com/img.jpg', 'Caption');
Http::assertSent(function ($request) {
return str_contains($request->url(), 'instance123/messages/image')
&& $request['image'] === 'https://example.com/img.jpg'
&& $request['caption'] === 'Caption';
});
}
public function test_send_document_posts_correct_payload(): void
{
Http::fake([
'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200),
]);
$this->client->sendDocument('+971501234567', 'https://example.com/file.pdf', 'invoice.pdf', 'Your invoice');
Http::assertSent(function ($request) {
return str_contains($request->url(), 'instance123/messages/document')
&& $request['document'] === 'https://example.com/file.pdf'
&& $request['filename'] === 'invoice.pdf'
&& $request['caption'] === 'Your invoice';
});
}
public function test_send_location_posts_correct_payload(): void
{
Http::fake([
'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200),
]);
$this->client->sendLocation('+971501234567', 25.197197, 55.2721877, 'Dubai, UAE');
Http::assertSent(function ($request) {
return str_contains($request->url(), 'instance123/messages/location')
&& $request['lat'] == 25.197197
&& $request['lng'] == 55.2721877
&& $request['address'] === 'Dubai, UAE';
});
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0f172a"/>
<rect x="4" y="4" width="24" height="24" rx="5" fill="#2563eb"/>
<text x="16" y="23" font-family="Arial Black, Arial, sans-serif" font-weight="900" font-size="18" fill="#ffffff" text-anchor="middle" letter-spacing="-1">S</text>
</svg>

After

Width:  |  Height:  |  Size: 355 B

@@ -41,6 +41,10 @@ $editProjectsJson = json_encode($editProjectsData);
$curProjectInList = $editProjects->contains('name', $curProject);
@endphp
<style>
.{{ $prefix }}-urgency-opt:hover { background:#f8fafc; }
</style>
{{-- ── Trigger button ── --}}
<button type="button" onclick="{{ $prefix }}Open()" class="btn-secondary btn-sm">
Edit
@@ -119,9 +123,67 @@ $curProjectInList = $editProjects->contains('name', $curProject);
<label class="form-label">Requested By <span class="text-red-500">*</span></label>
<input type="text" name="requested_by_name" value="{{ $curReqBy }}" required class="form-input" placeholder="Person's name">
</div>
<div>
<div style="position:relative;" id="{{ $prefix }}UrgencyWrapper">
<label class="form-label">Required Date / Urgency</label>
<input type="text" name="required_date_text" value="{{ $curReqDate }}" class="form-input" placeholder="e.g. Urgent, or 2026-06-01">
<input type="hidden" name="required_date_text" id="{{ $prefix }}UrgencyValue" value="{{ $curReqDate }}">
{{-- Trigger --}}
<button type="button" id="{{ $prefix }}UrgencyTrigger" onclick="{{ $prefix }}UrgencyToggle()"
style="width:100%;text-align:left;background:white;border:1.5px dashed #d1d5db;border-radius:0.5rem;padding:0.45rem 2rem 0.45rem 0.75rem;font-size:0.8rem;cursor:pointer;position:relative;min-height:2.35rem;display:flex;align-items:center;transition:border-color 0.15s;">
<span id="{{ $prefix }}UrgencyDisplay" style="display:flex;align-items:center;gap:0.5rem;flex:1;">
<span style="color:#9ca3af;font-size:0.8rem;">— Select urgency —</span>
</span>
<span style="position:absolute;right:0.6rem;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:0.7rem;">&#9660;</span>
</button>
{{-- Panel --}}
<div id="{{ $prefix }}UrgencyPanel"
style="display:none;position:absolute;z-index:10000;top:calc(100% + 4px);left:0;right:0;background:white;border:1px solid #e2e8f0;border-radius:0.75rem;box-shadow:0 12px 32px -4px rgba(0,0,0,0.18);overflow:hidden;">
<ul style="list-style:none;margin:0;padding:0.375rem;">
<li onclick="{{ $prefix }}UrgencySelect('Urgent','Urgent','#dc2626','#fef2f2')"
class="{{ $prefix }}-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#fef2f2;display:flex;align-items:center;justify-content:center;"><span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#dc2626;"></span></span>
<div><div style="font-size:0.8rem;font-weight:700;color:#dc2626;line-height:1.2;">Urgent</div><div style="font-size:0.68rem;color:#9ca3af;">Needed immediately</div></div>
</li>
<li onclick="{{ $prefix }}UrgencySelect('3 Days','3 Days','#ea580c','#fff7ed')"
class="{{ $prefix }}-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#fff7ed;display:flex;align-items:center;justify-content:center;"><span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#ea580c;"></span></span>
<div><div style="font-size:0.8rem;font-weight:700;color:#ea580c;line-height:1.2;">3 Days</div><div style="font-size:0.68rem;color:#9ca3af;">Within 3 days</div></div>
</li>
<li onclick="{{ $prefix }}UrgencySelect('1 Week','1 Week','#d97706','#fffbeb')"
class="{{ $prefix }}-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#fffbeb;display:flex;align-items:center;justify-content:center;"><span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#d97706;"></span></span>
<div><div style="font-size:0.8rem;font-weight:700;color:#d97706;line-height:1.2;">1 Week</div><div style="font-size:0.68rem;color:#9ca3af;">Within this week</div></div>
</li>
<li onclick="{{ $prefix }}UrgencySelect('2 Weeks','2 Weeks','#2563eb','#eff6ff')"
class="{{ $prefix }}-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#eff6ff;display:flex;align-items:center;justify-content:center;"><span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#2563eb;"></span></span>
<div><div style="font-size:0.8rem;font-weight:700;color:#2563eb;line-height:1.2;">2 Weeks</div><div style="font-size:0.68rem;color:#9ca3af;">Within 2 weeks</div></div>
</li>
<li onclick="{{ $prefix }}UrgencySelect('1 Month','1 Month','#16a34a','#f0fdf4')"
class="{{ $prefix }}-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#f0fdf4;display:flex;align-items:center;justify-content:center;"><span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#16a34a;"></span></span>
<div><div style="font-size:0.8rem;font-weight:700;color:#16a34a;line-height:1.2;">1 Month</div><div style="font-size:0.68rem;color:#9ca3af;">Within a month</div></div>
</li>
<li style="height:1px;background:#f1f5f9;margin:0.25rem 0;"></li>
<li id="{{ $prefix }}UrgencyDateOpt" onclick="{{ $prefix }}UrgencyDateOptClick()"
class="{{ $prefix }}-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#f5f3ff;display:flex;align-items:center;justify-content:center;">
<svg style="width:0.875rem;height:0.875rem;stroke:#7c3aed;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
</span>
<div style="flex:1;"><div style="font-size:0.8rem;font-weight:700;color:#7c3aed;line-height:1.2;">Specific Date</div><div style="font-size:0.68rem;color:#9ca3af;">Pick an exact date</div></div>
</li>
<li id="{{ $prefix }}UrgencyDatePickerRow" style="display:none;padding:0 0.75rem 0.5rem;">
<input type="date" id="{{ $prefix }}UrgencyDate"
style="width:100%;font-size:0.8rem;border:1.5px solid #7c3aed;border-radius:0.4rem;padding:0.35rem 0.5rem;outline:none;color:#7c3aed;box-sizing:border-box;"
onchange="{{ $prefix }}UrgencyDateChange(this.value)">
</li>
</ul>
</div>
</div>
<div>
<label class="form-label">Location / Site</label>
@@ -315,6 +377,87 @@ $curProjectInList = $editProjects->contains('name', $curProject);
}
window.{{ $prefix }}FilterLoc = {{ $prefix }}FilterLoc;
// ── Urgency colored dropdown ──────────────────────────────────────────────
var _urgencyMap{{ $prId }} = {
'Urgent': ['Urgent', '#dc2626','#fef2f2'],
'3 Days': ['3 Days', '#ea580c','#fff7ed'],
'1 Week': ['1 Week', '#d97706','#fffbeb'],
'2 Weeks': ['2 Weeks', '#2563eb','#eff6ff'],
'1 Month': ['1 Month', '#16a34a','#f0fdf4'],
};
function _urgencyBadge{{ $prId }}(label, color, bgColor) {
return '<span style="display:inline-flex;align-items:center;gap:0.4rem;background:' + bgColor + ';border:1px solid ' + color + '20;padding:0.15rem 0.6rem 0.15rem 0.4rem;border-radius:999px;">' +
'<span style="width:0.45rem;height:0.45rem;border-radius:50%;background:' + color + ';flex-shrink:0;"></span>' +
'<span style="font-size:0.78rem;font-weight:600;color:' + color + ';">' + label + '</span></span>';
}
function _urgencyDateBadge{{ $prId }}(label) {
return '<span style="display:inline-flex;align-items:center;gap:0.4rem;background:#f5f3ff;border:1px solid #7c3aed20;padding:0.15rem 0.6rem 0.15rem 0.4rem;border-radius:999px;">' +
'<svg style="width:0.7rem;height:0.7rem;stroke:#7c3aed;flex-shrink:0;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>' +
'<span style="font-size:0.78rem;font-weight:600;color:#7c3aed;">' + label + '</span></span>';
}
window.{{ $prefix }}UrgencyToggle = function() {
var p = document.getElementById('{{ $prefix }}UrgencyPanel');
p.style.display = p.style.display === 'none' ? 'block' : 'none';
};
window.{{ $prefix }}UrgencyClose = function() {
document.getElementById('{{ $prefix }}UrgencyPanel').style.display = 'none';
};
window.{{ $prefix }}UrgencySelect = function(value, label, color, bgColor) {
document.getElementById('{{ $prefix }}UrgencyValue').value = value;
document.getElementById('{{ $prefix }}UrgencyDisplay').innerHTML = _urgencyBadge{{ $prId }}(label, color, bgColor);
var t = document.getElementById('{{ $prefix }}UrgencyTrigger');
t.style.borderColor = color; t.style.borderStyle = 'solid';
document.getElementById('{{ $prefix }}UrgencyDatePickerRow').style.display = 'none';
{{ $prefix }}UrgencyClose();
};
window.{{ $prefix }}UrgencyDateOptClick = function() {
var row = document.getElementById('{{ $prefix }}UrgencyDatePickerRow');
row.style.display = row.style.display === 'none' ? '' : 'none';
if (row.style.display !== 'none') document.getElementById('{{ $prefix }}UrgencyDate').focus();
};
window.{{ $prefix }}UrgencyDateChange = function(val) {
if (!val) return;
document.getElementById('{{ $prefix }}UrgencyValue').value = val;
var d = new Date(val + 'T00:00:00');
var label = d.toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'});
document.getElementById('{{ $prefix }}UrgencyDisplay').innerHTML = _urgencyDateBadge{{ $prId }}(label);
var t = document.getElementById('{{ $prefix }}UrgencyTrigger');
t.style.borderColor = '#7c3aed'; t.style.borderStyle = 'solid';
{{ $prefix }}UrgencyClose();
};
// Close on outside click
document.addEventListener('click', function(e) {
var w = document.getElementById('{{ $prefix }}UrgencyWrapper');
if (w && !w.contains(e.target)) {{ $prefix }}UrgencyClose();
});
// Restore saved value on load
document.addEventListener('DOMContentLoaded', function() {
var val = document.getElementById('{{ $prefix }}UrgencyValue').value;
if (!val) return;
if (_urgencyMap{{ $prId }}[val]) {
var m = _urgencyMap{{ $prId }}[val];
{{ $prefix }}UrgencySelect(val, m[0], m[1], m[2]);
} else {
document.getElementById('{{ $prefix }}UrgencyDatePickerRow').style.display = '';
document.getElementById('{{ $prefix }}UrgencyDate').value = val;
var d2 = new Date(val + 'T00:00:00');
var lbl = d2.toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'});
document.getElementById('{{ $prefix }}UrgencyDisplay').innerHTML = _urgencyDateBadge{{ $prId }}(lbl);
var t2 = document.getElementById('{{ $prefix }}UrgencyTrigger');
t2.style.borderColor = '#7c3aed'; t2.style.borderStyle = 'solid';
}
});
// ── Cascading project → location dropdown ─────────────────────────────────
// On load: populate locations and pre-select the current ones
document.addEventListener('DOMContentLoaded', function () {
var curProj = document.getElementById('{{ $prefix }}Project').value;
@@ -1,20 +1,38 @@
@php
$hasErrors = $errors->any();
$mprProjects = \App\Models\Settings\ProjectSetting::active()
->with(['locations' => function ($q) { $q->where('is_active', true)->orderBy('name'); }])
->whereNotNull('company_id')
->with([
'company',
'locations' => function ($q) { $q->where('is_active', true)->orderBy('name'); },
])
->orderBy('name')
->get();
$mprProjectsJson = $mprProjects->map(function ($p) {
return [
'id' => $p->id,
'name' => $p->name,
'locations' => $p->locations->map(function ($l) {
'id' => $p->id,
'name' => $p->name,
'company_id' => $p->company_id,
'company_name' => $p->company ? $p->company->name : '',
'label' => ($p->company ? $p->company->name . ' — ' : '') . $p->name,
'locations' => $p->locations->map(function ($l) {
return ['name' => $l->name];
})->values(),
];
})->values();
$mprDepartments = \App\Models\Settings\Department::where('is_active', true)->orderBy('name')->get();
$mprDepartmentsJson = $mprDepartments->map(fn($d) => ['id' => $d->id, 'name' => $d->name, 'company_id' => $d->company_id])->values();
$mprUnits = ['PCS','NOS','KG','TON','MTR','SQM','LTR','BAG','BOX','ROLL','SET','EA','CANS','LOT'];
$today = date('Y-m-d');
@endphp
<style>
.mpr-proj-opt:hover { background:#eff6ff; border-left-color:#2563eb !important; }
.mpr-urgency-opt:hover { background:#f8fafc; }
</style>
{{-- Trigger button --}}
<button type="button" onclick="mprModalOpen()" class="btn-primary">
+ New Request
@@ -72,24 +90,139 @@ $mprProjectsJson = $mprProjects->map(function ($p) {
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1rem;">
<div>
<label class="form-label">Date <span class="text-red-500">*</span></label>
<input type="date" name="date" value="{{ old('date', date('Y-m-d')) }}" required class="form-input">
<input type="date" name="date" value="{{ old('date', $today) }}" required class="form-input">
</div>
<div>
<div style="position:relative;" id="mpr-project-wrapper">
<label class="form-label">Project / Site Name <span class="text-red-500">*</span></label>
<select name="project_name" id="mpr-project-select" required class="form-input" onchange="mprFilterLocations(this.value)">
<option value="">— Select Project —</option>
@foreach($mprProjects as $proj)
<option value="{{ $proj->name }}" {{ old('project_name') == $proj->name ? 'selected' : '' }} data-id="{{ $proj->id }}">{{ $proj->name }}</option>
@endforeach
</select>
{{-- Hidden input carries the actual value for form submission --}}
<input type="hidden" name="project_name" id="mpr-project-value" value="{{ old('project_name') }}">
{{-- Trigger --}}
<button type="button" id="mpr-project-trigger"
onclick="mprProjectToggle()"
style="width:100%;text-align:left;background:white;border:1px solid #d1d5db;border-radius:0.375rem;padding:0.4rem 2rem 0.4rem 0.625rem;font-size:0.875rem;color:#111827;cursor:pointer;position:relative;line-height:1.5;min-height:2.25rem;">
<span id="mpr-project-label" style="color:#9ca3af;">— Select Project —</span>
<span style="position:absolute;right:0.5rem;top:50%;transform:translateY(-50%);pointer-events:none;color:#6b7280;">&#9660;</span>
</button>
{{-- Dropdown panel --}}
<div id="mpr-project-panel"
style="display:none;position:absolute;z-index:9999;top:calc(100% + 2px);left:0;right:0;background:white;border:1px solid #d1d5db;border-radius:0.5rem;box-shadow:0 10px 25px -5px rgba(0,0,0,0.15);overflow:hidden;">
<div style="padding:0.5rem;">
<input type="text" id="mpr-project-search" placeholder="Search project or company…"
oninput="mprProjectSearch(this.value)"
style="width:100%;border:1px solid #e2e8f0;border-radius:0.375rem;padding:0.375rem 0.625rem;font-size:0.8rem;outline:none;box-sizing:border-box;">
</div>
<ul id="mpr-project-list"
style="max-height:13rem;overflow-y:auto;margin:0;padding:0 0 0.25rem 0;list-style:none;">
@foreach($mprProjects as $proj)
<li class="mpr-proj-opt"
data-value="{{ $proj->name }}"
data-label="{{ ($proj->company ? $proj->company->name . ' — ' : '') . $proj->name }}"
data-search="{{ strtolower(($proj->company ? $proj->company->name . ' ' : '') . $proj->name) }}"
onclick="mprProjectSelect('{{ $proj->name }}', '{{ addslashes(($proj->company ? $proj->company->name . ' — ' : '') . $proj->name) }}')"
style="padding:0.45rem 0.875rem;cursor:pointer;font-size:0.8rem;color:#111827;line-height:1.4;border-left:3px solid transparent;">
<span style="font-size:0.68rem;color:#6b7280;display:block;line-height:1.2;">{{ $proj->company ? $proj->company->name : '' }}</span>
{{ $proj->name }}
</li>
@endforeach
<li id="mpr-proj-empty" style="display:none;padding:0.625rem 0.875rem;font-size:0.8rem;color:#9ca3af;">No projects found.</li>
</ul>
</div>
</div>
<div>
<label class="form-label">Requested By <span class="text-red-500">*</span></label>
<input type="text" name="requested_by_name" value="{{ old('requested_by_name') }}" required class="form-input" placeholder="Person's name">
</div>
<div>
<div style="position:relative;" id="mpr-urgency-wrapper">
<label class="form-label">Required Date / Urgency</label>
<input type="text" name="required_date_text" value="{{ old('required_date_text') }}" class="form-input" placeholder="e.g. Urgent, or 2026-06-01">
<input type="hidden" name="required_date_text" id="mpr-urgency-value" value="{{ old('required_date_text') }}">
{{-- Trigger --}}
<button type="button" id="mpr-urgency-trigger" onclick="mprUrgencyToggle()"
style="width:100%;text-align:left;background:white;border:1.5px dashed #d1d5db;border-radius:0.5rem;padding:0.45rem 2rem 0.45rem 0.75rem;font-size:0.8rem;cursor:pointer;position:relative;min-height:2.35rem;display:flex;align-items:center;transition:border-color 0.15s;">
<span id="mpr-urgency-display" style="display:flex;align-items:center;gap:0.5rem;flex:1;">
<span style="color:#9ca3af;font-size:0.8rem;">— Select urgency —</span>
</span>
<span style="position:absolute;right:0.6rem;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:0.7rem;">&#9660;</span>
</button>
{{-- Panel --}}
<div id="mpr-urgency-panel"
style="display:none;position:absolute;z-index:10000;top:calc(100% + 4px);left:0;right:0;background:white;border:1px solid #e2e8f0;border-radius:0.75rem;box-shadow:0 12px 32px -4px rgba(0,0,0,0.18);overflow:hidden;">
<ul style="list-style:none;margin:0;padding:0.375rem;">
<li onclick="mprUrgencySelect('Urgent','Urgent','#dc2626','#fef2f2')"
class="mpr-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;transition:background 0.1s;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#fef2f2;display:flex;align-items:center;justify-content:center;">
<span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#dc2626;"></span>
</span>
<div>
<div style="font-size:0.8rem;font-weight:700;color:#dc2626;line-height:1.2;">Urgent</div>
<div style="font-size:0.68rem;color:#9ca3af;">Needed immediately</div>
</div>
</li>
<li onclick="mprUrgencySelect('3 Days','3 Days','#ea580c','#fff7ed')"
class="mpr-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;transition:background 0.1s;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#fff7ed;display:flex;align-items:center;justify-content:center;">
<span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#ea580c;"></span>
</span>
<div>
<div style="font-size:0.8rem;font-weight:700;color:#ea580c;line-height:1.2;">3 Days</div>
<div style="font-size:0.68rem;color:#9ca3af;">Within 3 days</div>
</div>
</li>
<li onclick="mprUrgencySelect('1 Week','1 Week','#d97706','#fffbeb')"
class="mpr-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;transition:background 0.1s;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#fffbeb;display:flex;align-items:center;justify-content:center;">
<span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#d97706;"></span>
</span>
<div>
<div style="font-size:0.8rem;font-weight:700;color:#d97706;line-height:1.2;">1 Week</div>
<div style="font-size:0.68rem;color:#9ca3af;">Within this week</div>
</div>
</li>
<li onclick="mprUrgencySelect('2 Weeks','2 Weeks','#2563eb','#eff6ff')"
class="mpr-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;transition:background 0.1s;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#eff6ff;display:flex;align-items:center;justify-content:center;">
<span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#2563eb;"></span>
</span>
<div>
<div style="font-size:0.8rem;font-weight:700;color:#2563eb;line-height:1.2;">2 Weeks</div>
<div style="font-size:0.68rem;color:#9ca3af;">Within 2 weeks</div>
</div>
</li>
<li onclick="mprUrgencySelect('1 Month','1 Month','#16a34a','#f0fdf4')"
class="mpr-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;margin-bottom:0.125rem;transition:background 0.1s;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#f0fdf4;display:flex;align-items:center;justify-content:center;">
<span style="width:0.55rem;height:0.55rem;border-radius:50%;background:#16a34a;"></span>
</span>
<div>
<div style="font-size:0.8rem;font-weight:700;color:#16a34a;line-height:1.2;">1 Month</div>
<div style="font-size:0.68rem;color:#9ca3af;">Within a month</div>
</div>
</li>
<li style="height:1px;background:#f1f5f9;margin:0.25rem 0;"></li>
<li id="mpr-urgency-date-opt" onclick="mprUrgencyDateOptClick()"
class="mpr-urgency-opt"
style="display:flex;align-items:center;gap:0.625rem;padding:0.55rem 0.75rem;border-radius:0.5rem;cursor:pointer;transition:background 0.1s;">
<span style="flex-shrink:0;width:2rem;height:2rem;border-radius:0.4rem;background:#f5f3ff;display:flex;align-items:center;justify-content:center;">
<svg style="width:0.875rem;height:0.875rem;stroke:#7c3aed;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</span>
<div style="flex:1;">
<div style="font-size:0.8rem;font-weight:700;color:#7c3aed;line-height:1.2;">Specific Date</div>
<div style="font-size:0.68rem;color:#9ca3af;">Pick an exact date</div>
</div>
</li>
<li id="mpr-urgency-datepicker-row" style="display:none;padding:0 0.75rem 0.5rem;">
<input type="date" id="mpr-urgency-date"
style="width:100%;font-size:0.8rem;border:1.5px solid #7c3aed;border-radius:0.4rem;padding:0.35rem 0.5rem;outline:none;color:#7c3aed;box-sizing:border-box;"
onchange="mprUrgencyDateChange(this.value)">
</li>
</ul>
</div>
</div>
<div>
<label class="form-label">Location / Site</label>
@@ -99,7 +232,12 @@ $mprProjectsJson = $mprProjects->map(function ($p) {
</div>
<div>
<label class="form-label">Department</label>
<input type="text" name="department" value="{{ old('department') }}" class="form-input" placeholder="e.g. Operations">
<select name="department" id="mpr-department-select" class="form-input">
<option value="">— Select Department —</option>
@foreach($mprDepartments as $dept)
<option value="{{ $dept->name }}" data-company="{{ $dept->company_id }}" {{ old('department') == $dept->name ? 'selected' : '' }}>{{ $dept->name }}</option>
@endforeach
</select>
</div>
</div>
</div>
@@ -119,7 +257,7 @@ $mprProjectsJson = $mprProjects->map(function ($p) {
<tr style="background:white;">
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;width:2.5rem;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">#</th>
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">Description <span style="color:#f87171;">*</span></th>
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;width:5rem;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">Unit</th>
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;width:6rem;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">Unit</th>
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;width:6rem;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">Qty <span style="color:#f87171;">*</span></th>
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;width:9rem;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">Purpose</th>
<th style="border:1px solid #e2e8f0;padding:0.5rem 0.625rem;text-align:left;width:8rem;font-size:0.65rem;font-weight:600;color:#94a3b8;text-transform:uppercase;">Req. Date</th>
@@ -136,8 +274,12 @@ $mprProjectsJson = $mprProjects->map(function ($p) {
style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Material description" required>
</td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
<input type="text" name="items[{{ $idx }}][unit]" value="{{ $oldItem['unit'] ?? '' }}"
style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="PCS…">
<select name="items[{{ $idx }}][unit]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;cursor:pointer;">
<option value="">—</option>
@foreach($mprUnits as $u)
<option value="{{ $u }}" {{ ($oldItem['unit'] ?? '') == $u ? 'selected' : '' }}>{{ $u }}</option>
@endforeach
</select>
</td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
<input type="number" name="items[{{ $idx }}][quantity_required]" value="{{ $oldItem['quantity_required'] ?? '' }}"
@@ -148,7 +290,7 @@ $mprProjectsJson = $mprProjects->map(function ($p) {
style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Purpose…">
</td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;">
<input type="date" name="items[{{ $idx }}][required_date]" value="{{ $oldItem['required_date'] ?? '' }}"
<input type="date" name="items[{{ $idx }}][required_date]" value="{{ $oldItem['required_date'] ?? $today }}"
style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;">
</td>
<td style="border:1px solid #e2e8f0;padding:0.25rem 0.4rem;text-align:center;">
@@ -204,6 +346,13 @@ document.addEventListener('keydown', function (e) {
document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); });
@endif
// Unit options HTML (shared between template rows and JS-added rows)
var mprUnitOptions = '<option value="">—</option>' +
@foreach($mprUnits as $u)
'<option value="{{ $u }}">{{ $u }}</option>' +
@endforeach
'';
// Dynamic rows
(function () {
var mprRowIndex = {{ count($oldItems ?? [[]]) }};
@@ -214,18 +363,26 @@ document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); });
});
}
function mprLastRowDate() {
var rows = document.querySelectorAll('#mpr-items-body .mpr-item-row');
if (!rows.length) return '{{ $today }}';
var lastDate = rows[rows.length - 1].querySelector('input[type="date"]');
return (lastDate && lastDate.value) ? lastDate.value : '{{ $today }}';
}
function mprNewRow() {
var idx = mprRowIndex++;
var tr = document.createElement('tr');
var idx = mprRowIndex++;
var date = mprLastRowDate();
var tr = document.createElement('tr');
tr.className = 'mpr-item-row';
tr.style.background = 'white';
tr.innerHTML =
'<td style="border:1px solid #e2e8f0;padding:0.375rem 0.625rem;text-align:center;color:#cbd5e1;font-size:0.75rem;" class="mpr-row-num"></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="text" name="items[' + idx + '][description]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Material description" required></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="text" name="items[' + idx + '][unit]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="PCS…"></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><select name="items[' + idx + '][unit]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;cursor:pointer;">' + mprUnitOptions + '</select></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="number" name="items[' + idx + '][quantity_required]" min="0.01" step="0.01" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="0" required></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="text" name="items[' + idx + '][purpose_use]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;" placeholder="Purpose…"></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="date" name="items[' + idx + '][required_date]" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;"></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.5rem;"><input type="date" name="items[' + idx + '][required_date]" value="' + date + '" style="width:100%;border:0;outline:none;font-size:0.8rem;background:transparent;"></td>' +
'<td style="border:1px solid #e2e8f0;padding:0.25rem 0.4rem;text-align:center;"><button type="button" class="mpr-remove-row" style="color:#f87171;background:none;border:none;cursor:pointer;font-size:1.1rem;font-weight:700;line-height:1;" onmouseover="this.style.color=\'#dc2626\'" onmouseout="this.style.color=\'#f87171\'">&times;</button></td>';
return tr;
}
@@ -253,11 +410,61 @@ document.addEventListener('DOMContentLoaded', function () { mprModalOpen(); });
});
})();
// Cascading project → location dropdown
var mprProjectsData = @json($mprProjectsJson);
var mprOldLocation = "{{ old('location') }}";
var mprOldProjectName = "{{ old('project_name') }}";
// ── Cascading data ────────────────────────────────────────────────────────────
var mprProjectsData = @json($mprProjectsJson);
var mprDepartmentsData = @json($mprDepartmentsJson);
var mprOldLocation = "{{ old('location') }}";
var mprOldProjectName = "{{ old('project_name') }}";
var mprOldDepartment = "{{ old('department') }}";
// ── Searchable project dropdown ───────────────────────────────────────────────
function mprProjectToggle() {
var panel = document.getElementById('mpr-project-panel');
var open = panel.style.display !== 'none';
if (open) {
mprProjectClose();
} else {
panel.style.display = 'block';
var search = document.getElementById('mpr-project-search');
search.value = '';
mprProjectSearch('');
setTimeout(function(){ search.focus(); }, 30);
}
}
function mprProjectClose() {
document.getElementById('mpr-project-panel').style.display = 'none';
}
function mprProjectSearch(q) {
var items = document.querySelectorAll('#mpr-project-list .mpr-proj-opt');
var empty = document.getElementById('mpr-proj-empty');
var term = q.trim().toLowerCase();
var visible = 0;
items.forEach(function(li) {
var match = !term || li.dataset.search.indexOf(term) !== -1;
li.style.display = match ? '' : 'none';
if (match) visible++;
});
empty.style.display = visible === 0 ? '' : 'none';
}
function mprProjectSelect(value, label) {
document.getElementById('mpr-project-value').value = value;
var lbl = document.getElementById('mpr-project-label');
lbl.textContent = label;
lbl.style.color = '#111827';
mprProjectClose();
mprOnProjectChange(value);
}
// Close panel when clicking outside
document.addEventListener('click', function(e) {
var wrapper = document.getElementById('mpr-project-wrapper');
if (wrapper && !wrapper.contains(e.target)) mprProjectClose();
});
// ── Location cascade ──────────────────────────────────────────────────────────
function mprFilterLocations(projectName) {
var sel = document.getElementById('mpr-location-select');
sel.innerHTML = '<option value="">— Select Location —</option>';
@@ -277,12 +484,138 @@ function mprFilterLocations(projectName) {
}
}
// On load: if old project value exists (validation error repopulation), trigger filter
// ── Department cascade ────────────────────────────────────────────────────────
function mprFilterDepartments(projectName) {
var sel = document.getElementById('mpr-department-select');
sel.innerHTML = '<option value="">— Select Department —</option>';
var companyId = null;
if (projectName) {
var proj = mprProjectsData.find(function(p){ return p.name === projectName; });
if (proj) companyId = proj.company_id;
}
var depts = companyId
? mprDepartmentsData.filter(function(d){ return d.company_id === companyId; })
: mprDepartmentsData;
depts.forEach(function(d){
var opt = document.createElement('option');
opt.value = d.name;
opt.textContent = d.name;
if (d.name === mprOldDepartment) opt.selected = true;
sel.appendChild(opt);
});
sel.disabled = depts.length === 0;
}
function mprOnProjectChange(projectName) {
mprFilterLocations(projectName);
mprFilterDepartments(projectName);
}
// ── Restore old values on validation error repopulation ───────────────────────
document.addEventListener('DOMContentLoaded', function(){
if (mprOldProjectName) {
mprFilterLocations(mprOldProjectName);
var proj = mprProjectsData.find(function(p){ return p.name === mprOldProjectName; });
if (proj) mprProjectSelect(proj.name, proj.label);
} else {
document.getElementById('mpr-location-select').disabled = true;
mprFilterDepartments('');
}
// Restore urgency pill
var oldUrgency = document.getElementById('mpr-urgency-value').value;
if (oldUrgency) mprUrgencyRestore(oldUrgency);
});
// ── Urgency colored dropdown ──────────────────────────────────────────────────
var mprUrgencyPresets = ['Urgent','3 Days','1 Week','2 Weeks','1 Month'];
function mprUrgencyToggle() {
var panel = document.getElementById('mpr-urgency-panel');
if (panel.style.display === 'none') {
panel.style.display = 'block';
} else {
mprUrgencyClose();
}
}
function mprUrgencyClose() {
document.getElementById('mpr-urgency-panel').style.display = 'none';
}
function mprUrgencySelect(value, label, color, bgColor) {
document.getElementById('mpr-urgency-value').value = value;
// Update trigger display
var display = document.getElementById('mpr-urgency-display');
display.innerHTML =
'<span style="display:inline-flex;align-items:center;gap:0.4rem;background:' + bgColor + ';border:1px solid ' + color + '20;padding:0.15rem 0.6rem 0.15rem 0.4rem;border-radius:999px;">' +
'<span style="width:0.45rem;height:0.45rem;border-radius:50%;background:' + color + ';flex-shrink:0;"></span>' +
'<span style="font-size:0.78rem;font-weight:600;color:' + color + ';">' + label + '</span>' +
'</span>';
// Update trigger border color
document.getElementById('mpr-urgency-trigger').style.borderColor = color;
document.getElementById('mpr-urgency-trigger').style.borderStyle = 'solid';
// Hide date picker row
document.getElementById('mpr-urgency-datepicker-row').style.display = 'none';
mprUrgencyClose();
}
function mprUrgencyDateOptClick() {
var row = document.getElementById('mpr-urgency-datepicker-row');
row.style.display = row.style.display === 'none' ? '' : 'none';
if (row.style.display !== 'none') {
document.getElementById('mpr-urgency-date').focus();
}
}
function mprUrgencyDateChange(val) {
if (!val) return;
document.getElementById('mpr-urgency-value').value = val;
// Format nicely for display
var d = new Date(val + 'T00:00:00');
var label = d.toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'});
var display = document.getElementById('mpr-urgency-display');
display.innerHTML =
'<span style="display:inline-flex;align-items:center;gap:0.4rem;background:#f5f3ff;border:1px solid #7c3aed20;padding:0.15rem 0.6rem 0.15rem 0.4rem;border-radius:999px;">' +
'<svg style="width:0.7rem;height:0.7rem;stroke:#7c3aed;flex-shrink:0;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>' +
'<span style="font-size:0.78rem;font-weight:600;color:#7c3aed;">' + label + '</span>' +
'</span>';
document.getElementById('mpr-urgency-trigger').style.borderColor = '#7c3aed';
document.getElementById('mpr-urgency-trigger').style.borderStyle = 'solid';
mprUrgencyClose();
}
// Close on outside click
document.addEventListener('click', function(e) {
var w = document.getElementById('mpr-urgency-wrapper');
if (w && !w.contains(e.target)) mprUrgencyClose();
});
// Restore on validation error repopulation
function mprUrgencyRestore(val) {
if (!val) return;
var presets = {
'Urgent': ['Urgent', '#dc2626','#fef2f2'],
'3 Days': ['3 Days', '#ea580c','#fff7ed'],
'1 Week': ['1 Week', '#d97706','#fffbeb'],
'2 Weeks': ['2 Weeks', '#2563eb','#eff6ff'],
'1 Month': ['1 Month', '#16a34a','#f0fdf4'],
};
if (presets[val]) {
mprUrgencySelect(val, presets[val][0], presets[val][1], presets[val][2]);
} else {
// It's a specific date string
document.getElementById('mpr-urgency-value').value = val;
document.getElementById('mpr-urgency-datepicker-row').style.display = '';
document.getElementById('mpr-urgency-date').value = val;
var d = new Date(val + 'T00:00:00');
var label = d.toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'});
var display = document.getElementById('mpr-urgency-display');
display.innerHTML =
'<span style="display:inline-flex;align-items:center;gap:0.4rem;background:#f5f3ff;border:1px solid #7c3aed20;padding:0.15rem 0.6rem 0.15rem 0.4rem;border-radius:999px;">' +
'<svg style="width:0.7rem;height:0.7rem;stroke:#7c3aed;flex-shrink:0;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>' +
'<span style="font-size:0.78rem;font-weight:600;color:#7c3aed;">' + label + '</span>' +
'</span>';
document.getElementById('mpr-urgency-trigger').style.borderColor = '#7c3aed';
document.getElementById('mpr-urgency-trigger').style.borderStyle = 'solid';
}
}
</script>
@@ -256,18 +256,15 @@
</div>{{-- /sup-step2 --}}
{{-- Step 3: Summary / confirmation (By Item only) --}}
{{-- Step 3: Links --}}
<div id="sup-step3" style="flex:1;display:none;flex-direction:column;min-height:0;">
<div id="sup-summary-body" style="flex:1;overflow-y:auto;overscroll-behavior:contain;padding:20px 24px;">
</div>
<div style="padding:14px 24px;border-top:1px solid #f1f5f9;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;background:#fafafa;">
<button type="button" onclick="backToEdit()"
style="display:flex;align-items:center;gap:5px;font-size:13px;font-weight:600;color:#64748b;background:none;border:1px solid #e2e8f0;border-radius:8px;cursor:pointer;padding:8px 16px;">
← Back to edit
</button>
<button type="button" onclick="document.getElementById('sup-form').submit()"
<div style="font-size:12px;color:#64748b;" id="sup-link-count"></div>
<button type="button" onclick="doneWithLinks()"
style="padding:8px 22px;background:#16a34a;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;">
Confirm &amp; Send →
Done ✓
</button>
</div>
</div>
@@ -520,69 +517,117 @@ function updateFooter() {
}
}
var _rfqRedirect = null;
function submitSuppliers() {
if (_supTab === 'global') {
var checked = document.querySelectorAll('#sup-list input[type="checkbox"]:checked:not([disabled])');
if (checked.length === 0) { showToast('Please select at least one supplier.', 'warn'); return; }
document.getElementById('sup-form').submit();
} else {
var checked = document.querySelectorAll('input[name^="item_suppliers["]:checked:not([disabled])');
if (checked.length === 0) { showToast('Please assign at least one supplier to an item.', 'warn'); return; }
showSummary();
}
// Show step 3 with loading state immediately
document.getElementById('sup-summary-body').innerHTML =
'<div style="text-align:center;padding:40px;color:#64748b;font-size:13px;">Generating links…</div>';
document.getElementById('sup-modal-title').textContent = 'Quote Links';
document.getElementById('sup-modal-subtitle').textContent = 'One-time-use links for each supplier';
document.getElementById('sup-step2').style.display = 'none';
document.getElementById('sup-step3').style.display = 'flex';
var form = document.getElementById('sup-form');
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
fetch(form.action, {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': CSRF },
body: new FormData(form),
}).then(function(r) {
return r.json().then(function(body) {
if (!r.ok) return Promise.reject(body);
return body;
});
}).then(function(data) {
_rfqRedirect = data.redirect || null;
showLinks(data.invitations || []);
}).catch(function(err) {
document.getElementById('sup-step3').style.display = 'none';
document.getElementById('sup-step2').style.display = 'flex';
document.getElementById('sup-modal-title').textContent = 'Select Suppliers';
document.getElementById('sup-modal-subtitle').textContent = 'Choose who receives the quote request';
showToast((err && err.message) || 'Something went wrong.', 'error');
});
}
function escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function showSummary() {
var chanLabel = {
email: '<span style="background:#eff6ff;color:#2563eb;padding:3px 10px;border-radius:8px;font-size:12px;font-weight:700;">Email</span>',
whatsapp: '<span style="background:#f0fdf4;color:#15803d;padding:3px 10px;border-radius:8px;font-size:12px;font-weight:700;">WhatsApp</span>',
both: '<span style="background:#fef3c7;color:#92400e;padding:3px 10px;border-radius:8px;font-size:12px;font-weight:700;">Email + WA</span>',
};
var html = '<div style="font-size:13px;font-weight:700;color:#0f172a;margin-bottom:16px;">Review assignments before sending</div>';
var _chanBadge = {
email: '<span style="background:#eff6ff;color:#2563eb;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:700;flex-shrink:0;">Email</span>',
whatsapp: '<span style="background:#f0fdf4;color:#15803d;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:700;flex-shrink:0;">WhatsApp</span>',
both: '<span style="background:#fef3c7;color:#92400e;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:700;flex-shrink:0;">Email + WA</span>',
};
var btns = document.querySelectorAll('[id^="idd-btn-"]');
btns.forEach(function(btn) {
var itemId = btn.id.replace('idd-btn-', '');
var checked = document.querySelectorAll('input[name="item_suppliers[' + itemId + '][]"]:checked:not([disabled])');
if (checked.length === 0) return;
var name = btn.dataset.itemname || ('Item ' + itemId);
var qty = btn.dataset.itemqty || '';
html += '<div style="margin-bottom:12px;border:1.5px solid #e2e8f0;border-radius:10px;overflow:hidden;">';
html += '<div style="padding:10px 14px;background:#f8fafc;border-bottom:1px solid #e2e8f0;">';
html += '<div style="font-size:13px;font-weight:700;color:#0f172a;">' + escHtml(name) + '</div>';
if (qty) html += '<div style="font-size:11px;color:#94a3b8;margin-top:1px;">Qty: ' + escHtml(qty) + '</div>';
html += '</div>';
checked.forEach(function(cb) {
var supName = cb.dataset.supname || cb.value;
var chan = (document.getElementById('ichan-val-' + cb.value) || {}).value || 'email';
var cl = chanLabel[chan] || '<span style="background:#f1f5f9;color:#475569;padding:3px 10px;border-radius:8px;font-size:12px;font-weight:700;">' + escHtml(chan) + '</span>';
html += '<div style="padding:9px 14px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #f8fafc;">';
html += '<span style="font-size:13px;font-weight:600;color:#0f172a;">' + escHtml(supName) + '</span>';
html += cl;
function showLinks(invitations) {
var html = '';
if (!invitations || invitations.length === 0) {
html = '<div style="padding:30px;text-align:center;color:#94a3b8;font-size:13px;">No new invitations were created.</div>';
} else {
html += '<div style="font-size:12px;color:#64748b;margin-bottom:16px;">Each link is private to the supplier and valid until submitted or expired.</div>';
invitations.forEach(function(inv, i) {
var badge = _chanBadge[inv.channel] || '';
html += '<div style="margin-bottom:10px;border:1.5px solid #e2e8f0;border-radius:10px;overflow:hidden;">';
html += '<div style="padding:9px 14px;background:#f8fafc;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;justify-content:space-between;gap:8px;">';
html += '<span style="font-size:13px;font-weight:700;color:#0f172a;">' + escHtml(inv.supplier_name) + '</span>';
html += badge;
html += '</div>';
html += '<div style="padding:10px 14px;display:flex;align-items:center;gap:8px;">';
html += '<a href="' + escHtml(inv.url) + '" target="_blank"'
+ ' style="flex:1;display:flex;align-items:center;gap:8px;padding:9px 14px;'
+ 'background:#eff6ff;border:1.5px solid #bfdbfe;border-radius:8px;text-decoration:none;'
+ 'color:#1d4ed8;font-size:12px;font-weight:600;transition:background .15s;overflow:hidden;"'
+ ' onmouseover="this.style.background=\'#dbeafe\'" onmouseout="this.style.background=\'#eff6ff\'">'
+ '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" style="flex-shrink:0;">'
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>'
+ '</svg>'
+ '<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Open Quote Link</span>'
+ '</a>';
html += '<button type="button" data-idx="' + i + '" data-url="' + escHtml(inv.url) + '" onclick="copyLink(this)"'
+ ' style="flex-shrink:0;padding:9px 14px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;">Copy Link</button>';
html += '</div>';
html += '</div>';
});
html += '</div>';
});
}
document.getElementById('sup-summary-body').innerHTML = html;
document.getElementById('sup-modal-title').textContent = 'Confirm Assignments';
document.getElementById('sup-modal-subtitle').textContent = 'Review before sending to suppliers';
document.getElementById('sup-step2').style.display = 'none';
document.getElementById('sup-step3').style.display = 'flex';
document.getElementById('sup-modal-title').textContent = 'Quote Links Ready';
document.getElementById('sup-modal-subtitle').textContent = 'Share with suppliers via their preferred channel';
var countEl = document.getElementById('sup-link-count');
if (countEl) countEl.textContent = invitations.length + ' link' + (invitations.length === 1 ? '' : 's') + ' generated';
}
function backToEdit() {
document.getElementById('sup-modal-title').textContent = 'Select Suppliers';
document.getElementById('sup-modal-subtitle').textContent = 'Choose who receives the quote request';
document.getElementById('sup-step3').style.display = 'none';
document.getElementById('sup-step2').style.display = 'flex';
function copyLink(btn) {
var url = btn.dataset.url;
if (!url) return;
navigator.clipboard.writeText(url).then(function() {
btn.textContent = 'Copied!';
btn.style.background = '#16a34a';
setTimeout(function() { btn.textContent = 'Copy'; btn.style.background = '#2563eb'; }, 2000);
}).catch(function() {
// Fallback for older browsers
var ta = document.createElement('textarea');
ta.value = url; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); btn.textContent = 'Copied!'; btn.style.background = '#16a34a';
setTimeout(function() { btn.textContent = 'Copy'; btn.style.background = '#2563eb'; }, 2000);
} catch(e) { showToast('Could not copy — please copy manually.', 'warn'); }
document.body.removeChild(ta);
});
}
function doneWithLinks() {
if (_rfqRedirect) { window.location.href = _rfqRedirect; }
else { window.location.reload(); }
}
</script>
+117 -1
View File
@@ -4,7 +4,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'Dashboard') — SteelERP</title>
<title>SteelERP @hasSection('title') — @yield('title') @endif</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="alternate icon" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700&display=swap" rel="stylesheet"/>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@@ -197,6 +199,17 @@
</svg>
Integrations
</a>
<a href="{{ route('settings.vat') }}" style="
display:flex; align-items:center; gap:8px;
padding:7px 12px 7px 24px; border-radius:7px; margin-bottom:1px;
font-size:13px; text-decoration:none;
{{ request()->routeIs('settings.vat*') ? 'background:#1e293b;color:#fff;font-weight:500;' : 'color:#94a3b8;' }}
" onmouseover="if(!this.style.color.includes('fff'))this.style.color='#e2e8f0'" onmouseout="if(!this.style.background.includes('1e293b'))this.style.color='#94a3b8'">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink:0;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/>
</svg>
VAT Settings
</a>
@endrole
<div style="height:16px;"></div>
@@ -249,6 +262,38 @@
<div style="display:flex;align-items:center;gap:16px;">
<span style="font-size:12px;color:#94a3b8;">{{ now()->format('l, d M Y') }}</span>
<div style="width:1px;height:20px;background:#e2e8f0;"></div>
{{-- Bell notification --}}
<div style="position:relative;" id="bell-wrap">
<button id="bell-btn" onclick="toggleBellDropdown()" title="Notifications"
style="position:relative;width:34px;height:34px;border-radius:9px;border:1px solid #e2e8f0;
background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#64748b;
transition:background .15s,color .15s;"
onmouseover="this.style.background='#f1f5f9'" onmouseout="this.style.background='#fff'">
<svg width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg>
<span id="bell-badge" style="display:none;position:absolute;top:-4px;right:-4px;
background:#ef4444;color:#fff;font-size:10px;font-weight:700;
min-width:17px;height:17px;border-radius:9px;padding:0 4px;
display:none;align-items:center;justify-content:center;border:2px solid #fff;"></span>
</button>
{{-- Dropdown --}}
<div id="bell-dropdown" style="display:none;position:absolute;top:42px;right:0;
background:#fff;border:1.5px solid #e2e8f0;border-radius:14px;
box-shadow:0 12px 32px rgba(0,0,0,.12);width:320px;z-index:9000;overflow:hidden;">
<div style="padding:12px 16px;border-bottom:1px solid #f1f5f9;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:13px;font-weight:700;color:#0f172a;">Notifications</span>
<button onclick="markAllRead()" style="font-size:11px;color:#2563eb;background:none;border:none;cursor:pointer;font-weight:600;">Mark all read</button>
</div>
<div id="bell-list" style="max-height:320px;overflow-y:auto;">
<div style="padding:24px;text-align:center;color:#94a3b8;font-size:13px;" id="bell-empty">No new notifications</div>
</div>
</div>
</div>
<div style="width:1px;height:20px;background:#e2e8f0;"></div>
<div style="display:flex;align-items:center;gap:8px;">
<div style="width:32px;height:32px;border-radius:50%;background:#2563eb;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff;">
@@ -493,6 +538,77 @@ document.addEventListener('keydown', function(e) {
});
</script>
<script>
// ── Bell notifications ────────────────────────────────────
(function() {
var bellOpen = false;
function fetchNotifications() {
fetch('{{ route('notifications.unread') }}', { headers: { 'Accept': 'application/json' } })
.then(function(r){ return r.json(); })
.then(function(data) {
var badge = document.getElementById('bell-badge');
var list = document.getElementById('bell-list');
var empty = document.getElementById('bell-empty');
if (!badge) return;
if (data.count > 0) {
badge.textContent = data.count > 9 ? '9+' : data.count;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
if (data.items && data.items.length > 0) {
empty.style.display = 'none';
var html = '';
data.items.forEach(function(n) {
html += '<a href="' + n.go_url + '" style="display:block;padding:12px 16px;border-bottom:1px solid #f8fafc;text-decoration:none;transition:background .1s;" onmouseover="this.style.background=\'#f8fafc\'" onmouseout="this.style.background=\'transparent\'">';
html += '<div style="font-size:12px;font-weight:600;color:#0f172a;line-height:1.4;">' + n.message + '</div>';
html += '<div style="font-size:11px;color:#94a3b8;margin-top:2px;">' + n.ago + '</div>';
html += '</a>';
});
list.innerHTML = html + '<div id="bell-empty" style="display:none;"></div>';
} else {
list.innerHTML = '<div style="padding:24px;text-align:center;color:#94a3b8;font-size:13px;" id="bell-empty">No new notifications</div>';
}
}).catch(function(){});
}
window.toggleBellDropdown = function() {
var dd = document.getElementById('bell-dropdown');
bellOpen = !bellOpen;
dd.style.display = bellOpen ? 'block' : 'none';
if (bellOpen) fetchNotifications();
};
window.markAllRead = function() {
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
fetch('{{ route('notifications.read-all') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json' }
}).then(function() {
document.getElementById('bell-badge').style.display = 'none';
document.getElementById('bell-dropdown').style.display = 'none';
bellOpen = false;
});
};
// Close dropdown on outside click
document.addEventListener('click', function(e) {
var wrap = document.getElementById('bell-wrap');
if (wrap && !wrap.contains(e.target)) {
document.getElementById('bell-dropdown').style.display = 'none';
bellOpen = false;
}
});
// Poll every 30 seconds
fetchNotifications();
setInterval(fetchNotifications, 30000);
}());
</script>
<script>
function toggleSidebar() {
var s = document.getElementById('sidebar');
+3 -1
View File
@@ -5,7 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<title>SteelERP</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="alternate icon" href="/favicon.ico">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
+222 -10
View File
@@ -300,23 +300,39 @@
Quotes ({{ $pr->supplierQuotes->count() }})
</h3>
<div style="display:flex;flex-direction:column;gap:8px;">
@php $minTotal = $pr->supplierQuotes->min('total_amount'); @endphp
@foreach($pr->supplierQuotes->sortBy('total_amount') as $quote)
<div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;padding:8px 10px;border-radius:8px;
background:{{ $quote->is_awarded ? '#f0fdf4' : '#f8fafc' }};
border:1px solid {{ $quote->is_awarded ? '#bbf7d0' : '#f1f5f9' }};">
<div style="font-weight:600;color:#0f172a;">{{ $quote->supplier->name }}</div>
<div style="color:{{ $quote->is_awarded ? '#15803d' : '#374151' }};font-weight:700;">
{{ number_format($quote->total_amount, 2) }}
@php $isLowest = !$quote->is_awarded && $pr->supplierQuotes->count() > 1 && (float)$quote->total_amount === (float)$minTotal; @endphp
<div onclick="openQuoteModal({{ $quote->id }})"
style="display:flex;justify-content:space-between;align-items:center;font-size:12px;padding:8px 10px;border-radius:8px;cursor:pointer;transition:box-shadow .15s,border-color .15s;
background:{{ $quote->is_awarded ? '#f0fdf4' : ($isLowest ? '#eff6ff' : '#f8fafc') }};
border:1px solid {{ $quote->is_awarded ? '#bbf7d0' : ($isLowest ? '#bfdbfe' : '#f1f5f9') }};"
onmouseenter="this.style.boxShadow='0 2px 8px rgba(0,0,0,.08)';this.style.borderColor='{{ $quote->is_awarded ? '#86efac' : ($isLowest ? '#93c5fd' : '#e2e8f0') }}';"
onmouseleave="this.style.boxShadow='';this.style.borderColor='{{ $quote->is_awarded ? '#bbf7d0' : ($isLowest ? '#bfdbfe' : '#f1f5f9') }}';">
<div>
<div style="font-weight:600;color:#0f172a;">{{ $quote->supplier->name }}</div>
@if($isLowest)
<div style="font-size:10px;color:#2563eb;font-weight:700;margin-top:1px;">LOWEST</div>
@endif
</div>
<div style="display:flex;align-items:center;gap:6px;">
<div style="color:{{ $quote->is_awarded ? '#15803d' : ($isLowest ? '#2563eb' : '#374151') }};font-weight:700;">
BD {{ number_format($quote->total_amount, 3) }}
</div>
@if($quote->is_awarded)
<span style="font-size:10px;background:#22c55e;color:#fff;padding:1px 6px;border-radius:10px;margin-left:4px;">Awarded</span>
<span style="font-size:10px;background:#22c55e;color:#fff;padding:1px 6px;border-radius:10px;">✓</span>
@else
<svg width="12" height="12" fill="none" stroke="#94a3b8" stroke-width="2" viewBox="0 0 24 24" style="flex-shrink:0;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
@endif
</div>
</div>
@endforeach
</div>
<a href="{{ route('purchase.requests.quotes', $pr) }}"
style="display:block;margin-top:12px;text-align:center;font-size:12px;color:#2563eb;text-decoration:none;font-weight:600;">
View All Quotes →
<a href="{{ route('purchase.requests.compare', $pr) }}"
style="display:block;margin-top:12px;text-align:center;font-size:12px;color:#f59e0b;text-decoration:none;font-weight:700;padding:7px;border:1.5px solid #fde68a;border-radius:8px;background:#fffbeb;">
Compare All Quotes →
</a>
</div>
@endif
@@ -401,6 +417,202 @@
<x-purchase.supplier-select-modal :pr="$pr" :suppliers="$suppliers" :selectedIds="$selectedIds" />
{{-- ============================================================
QUOTE DETAIL MODAL
============================================================ --}}
<div id="quote-modal" class="pipe-modal" role="dialog" aria-modal="true" onclick="if(event.target===this)closeQuoteModal()">
<div style="background:#fff;border-radius:20px;width:100%;max-width:600px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 30px 60px rgba(0,0,0,.25);overflow:hidden;">
{{-- Header --}}
<div id="qm-header" style="background:linear-gradient(135deg,#f59e0b,#d97706);padding:20px 24px;display:flex;align-items:flex-start;justify-content:space-between;flex-shrink:0;">
<div>
<div style="font-size:11px;font-weight:600;color:rgba(255,255,255,.7);text-transform:uppercase;letter-spacing:.06em;">Supplier Quote</div>
<div id="qm-supplier" style="font-size:19px;font-weight:700;color:#fff;margin-top:4px;"></div>
<div id="qm-submitted" style="font-size:12px;color:rgba(255,255,255,.75);margin-top:2px;"></div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="text-align:right;">
<div style="font-size:11px;font-weight:600;color:rgba(255,255,255,.7);">Total</div>
<div id="qm-total" style="font-size:22px;font-weight:800;color:#fff;"></div>
</div>
<button onclick="closeQuoteModal()"
style="width:32px;height:32px;border-radius:8px;border:none;background:rgba(255,255,255,.2);cursor:pointer;font-size:18px;color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;">×</button>
</div>
</div>
{{-- Body --}}
<div style="overflow-y:auto;flex:1;">
{{-- Meta row --}}
<div id="qm-meta" style="display:flex;gap:0;border-bottom:1px solid #f1f5f9;"></div>
{{-- Items table --}}
<div style="padding:20px 24px 0;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;">Line Items</div>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:12.5px;" id="qm-items-table">
<thead>
<tr style="background:#f8fafc;">
<th style="padding:8px 10px;text-align:left;color:#64748b;font-weight:600;border-radius:6px 0 0 6px;">Item</th>
<th style="padding:8px 10px;text-align:right;color:#64748b;font-weight:600;white-space:nowrap;">Qty</th>
<th style="padding:8px 10px;text-align:right;color:#64748b;font-weight:600;white-space:nowrap;">Unit Price</th>
<th style="padding:8px 10px;text-align:right;color:#64748b;font-weight:600;border-radius:0 6px 6px 0;white-space:nowrap;">Total</th>
</tr>
</thead>
<tbody id="qm-items"></tbody>
</table>
</div>
</div>
{{-- Notes --}}
<div id="qm-notes-wrap" style="margin:14px 24px 0;padding:12px 14px;background:#f8fafc;border-radius:10px;border:1px solid #f1f5f9;display:none;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;">Notes</div>
<div id="qm-notes" style="font-size:13px;color:#374151;"></div>
</div>
{{-- Awarded banner --}}
<div id="qm-awarded-banner" style="margin:14px 24px 0;padding:10px 14px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:10px;font-size:13px;font-weight:700;color:#15803d;display:none;">
✓ This quote was awarded
</div>
<div style="height:20px;"></div>
</div>
{{-- Footer --}}
<div style="padding:16px 24px;border-top:1px solid #f1f5f9;display:flex;gap:10px;flex-shrink:0;background:#fff;">
<button onclick="closeQuoteModal()"
style="flex:1;padding:10px;border:1.5px solid #e2e8f0;border-radius:9px;font-size:13px;font-weight:600;color:#475569;background:#f8fafc;cursor:pointer;">
Close
</button>
<a id="qm-compare-btn" href="{{ route('purchase.requests.compare', $pr) }}"
style="flex:2;padding:10px;background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff;border:none;border-radius:9px;font-size:13px;font-weight:700;cursor:pointer;text-decoration:none;display:flex;align-items:center;justify-content:center;gap:6px;">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
Compare All Quotes
</a>
</div>
</div>
</div>
@php
$quoteDataForJs = $pr->supplierQuotes->keyBy('id')->map(function($q) {
return [
'id' => $q->id,
'supplier' => $q->supplier->name,
'total' => number_format($q->total_amount, 3),
'isAwarded' => (bool)$q->is_awarded,
'leadTime' => $q->lead_time_days !== null ? $q->lead_time_days . ' days' : null,
'paymentTerms'=> $q->payment_terms,
'notes' => $q->notes,
'submittedAt' => $q->submitted_at->format('d M Y, H:i'),
'items' => $q->items->map(function($i) {
return [
'description' => $i->description,
'supplierDescription' => $i->supplier_description,
'quantity' => $i->quantity,
'unit' => $i->unit,
'unitPrice' => number_format($i->unit_price, 3),
'totalPrice' => number_format($i->total_price, 3),
'isVatable' => (bool)$i->is_vatable,
'notAvailable' => (bool)$i->not_available,
];
})->values(),
];
});
@endphp
<script>
var QUOTE_DATA = @json($quoteDataForJs);
</script>
<script>
// ---- Quote detail modal ----
(function () {
window.openQuoteModal = function (id) {
var q = QUOTE_DATA[id];
if (!q) return;
// Header
document.getElementById('qm-supplier').textContent = q.supplier;
document.getElementById('qm-total').textContent = 'BD ' + q.total;
document.getElementById('qm-submitted').textContent = 'Submitted ' + q.submittedAt;
// Awarded header tint
var hdr = document.getElementById('qm-header');
hdr.style.background = q.isAwarded
? 'linear-gradient(135deg,#16a34a,#15803d)'
: 'linear-gradient(135deg,#f59e0b,#d97706)';
// Meta chips
var meta = document.getElementById('qm-meta');
meta.innerHTML = '';
var chips = [];
if (q.leadTime) chips.push(['⏱ Lead Time', q.leadTime]);
if (q.paymentTerms) chips.push(['💳 Payment', q.paymentTerms]);
chips.forEach(function (c) {
var d = document.createElement('div');
d.style.cssText = 'flex:1;padding:10px 16px;border-right:1px solid #f1f5f9;';
d.innerHTML = '<div style="font-size:10px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em;margin-bottom:2px;">' + c[0] + '</div>'
+ '<div style="font-size:13px;font-weight:600;color:#0f172a;">' + c[1] + '</div>';
meta.appendChild(d);
});
// Items
var tbody = document.getElementById('qm-items');
tbody.innerHTML = '';
q.items.forEach(function (item, i) {
var tr = document.createElement('tr');
tr.style.borderTop = i > 0 ? '1px solid #f8fafc' : '';
if (item.notAvailable) {
tr.innerHTML =
'<td style="padding:8px 10px;color:#0f172a;font-weight:500;">' +
escHtml(item.description) +
(item.supplierDescription ? '<div style="font-size:11px;color:#94a3b8;font-style:italic;margin-top:2px;">' + escHtml(item.supplierDescription) + '</div>' : '') +
'</td>' +
'<td style="padding:8px 10px;text-align:right;color:#94a3b8;">' + item.quantity + '</td>' +
'<td colspan="2" style="padding:8px 10px;text-align:center;">' +
'<span style="font-size:11px;font-weight:700;color:#dc2626;background:#fef2f2;padding:2px 8px;border-radius:4px;border:1px solid #fecaca;">N/A</span>' +
'</td>';
} else {
tr.innerHTML =
'<td style="padding:8px 10px;color:#0f172a;font-weight:500;">' +
escHtml(item.description) +
(item.supplierDescription ? '<div style="font-size:11px;color:#64748b;font-style:italic;margin-top:2px;">' + escHtml(item.supplierDescription) + ' <span style="background:#fef3c7;color:#92400e;font-size:9px;font-weight:700;padding:1px 4px;border-radius:3px;border:1px solid #fde68a;font-style:normal;">adjusted</span></div>' : '') +
'</td>' +
'<td style="padding:8px 10px;text-align:right;color:#64748b;">' + item.quantity + (item.unit ? ' ' + escHtml(item.unit) : '') + '</td>' +
'<td style="padding:8px 10px;text-align:right;font-weight:600;color:#0f172a;">' +
'BD ' + item.unitPrice +
(item.isVatable ? ' <span title="VAT applicable" style="font-size:9px;font-weight:700;color:#0ea5e9;background:#e0f2fe;padding:1px 4px;border-radius:3px;border:1px solid #bae6fd;">VAT</span>' : '') +
'</td>' +
'<td style="padding:8px 10px;text-align:right;font-weight:700;color:#0f172a;">BD ' + item.totalPrice + '</td>';
}
tbody.appendChild(tr);
});
// Notes
var nw = document.getElementById('qm-notes-wrap');
if (q.notes) {
document.getElementById('qm-notes').textContent = q.notes;
nw.style.display = 'block';
} else {
nw.style.display = 'none';
}
// Awarded banner
document.getElementById('qm-awarded-banner').style.display = q.isAwarded ? 'block' : 'none';
document.getElementById('quote-modal').classList.add('open');
};
window.closeQuoteModal = function () {
document.getElementById('quote-modal').classList.remove('open');
};
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
}());
</script>
<script>
// ---- Signature modal ----
@@ -48,7 +48,7 @@
{{-- Per-item rows --}}
@foreach($items as $i => $reqItem)
@php
$rowPrices = $quotes->map(fn($q) => optional($q->items->get($i))->unit_price)->filter()->values();
$rowPrices = $quotes->map(fn($q) => ($q->items->get($i) && !$q->items->get($i)->not_available) ? $q->items->get($i)->unit_price : null)->filter()->values();
$minPrice = $rowPrices->count() ? $rowPrices->min() : null;
@endphp
<tr>
@@ -57,14 +57,24 @@
<div style="font-size:11px;color:#94a3b8;margin-top:2px;">Qty: {{ $reqItem->quantity }} {{ $reqItem->unit }}</div>
</td>
@foreach($quotes as $q)
@php $qItem = $q->items->get($i); $isMin = $qItem && $minPrice !== null && (float)$qItem->unit_price === (float)$minPrice && $rowPrices->count() > 1; @endphp
@php $qItem = $q->items->get($i); $isMin = $qItem && !$qItem->not_available && $minPrice !== null && (float)$qItem->unit_price === (float)$minPrice && $rowPrices->count() > 1; @endphp
<td style="padding:10px 14px;border-bottom:1px solid #f1f5f9;text-align:center;background:{{ $isMin ? '#f0fdf4' : '' }};">
@if($qItem)
<div style="font-weight:600;color:{{ $isMin ? '#15803d' : '#0f172a' }};">
@if($isMin)<span style="font-size:10px;font-weight:700;color:#15803d;display:block;">LOWEST</span>@endif
BD {{ number_format($qItem->unit_price, 3) }}
</div>
<div style="font-size:11px;color:#64748b;margin-top:2px;">BD {{ number_format($qItem->total_price, 3) }}</div>
@if($qItem->not_available)
<span style="font-size:11px;font-weight:700;color:#dc2626;background:#fef2f2;padding:2px 8px;border-radius:4px;border:1px solid #fecaca;">Not available</span>
@else
@if($qItem->supplier_description)
<div style="font-size:10px;color:#64748b;margin-bottom:2px;font-style:italic;">
"{{ $qItem->supplier_description }}"
<span style="background:#fef3c7;color:#92400e;font-size:9px;font-weight:700;padding:1px 5px;border-radius:3px;border:1px solid #fde68a;font-style:normal;margin-left:2px;">adjusted</span>
</div>
@endif
<div style="font-weight:600;color:{{ $isMin ? '#15803d' : '#0f172a' }};">
@if($isMin)<span style="font-size:10px;font-weight:700;color:#15803d;display:block;">LOWEST</span>@endif
BD {{ number_format($qItem->unit_price, 3) }}
</div>
<div style="font-size:11px;color:#64748b;margin-top:2px;">BD {{ number_format($qItem->total_price, 3) }}</div>
@endif
@else
<span style="color:#e2e8f0;">—</span>
@endif
+275 -34
View File
@@ -6,31 +6,50 @@
<title>Quote Request — {{ $purchaseRequest->request_number }}</title>
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;min-height:100vh;padding:20px 16px;}
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.08);max-width:740px;margin:0 auto;overflow:hidden;}
.hd{background:linear-gradient(135deg,#2563eb,#1d4ed8);padding:28px 32px;}
.body{padding:32px;}
body{font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;min-height:100vh;}
label.fl{display:block;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px;}
input[type=text],input[type=number],textarea{width:100%;padding:9px 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:13px;outline:none;}
input[type=text],input[type=number],textarea{width:100%;padding:9px 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:13px;outline:none;background:#fff;}
input:focus,textarea:focus{border-color:#2563eb;}
.btn{width:100%;padding:14px;background:linear-gradient(135deg,#2563eb,#1d4ed8);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:700;cursor:pointer;margin-top:24px;}
.btn{width:100%;padding:15px;background:linear-gradient(135deg,#2563eb,#1d4ed8);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:700;cursor:pointer;}
table{width:100%;border-collapse:collapse;font-size:13px;}
thead th{background:#f8fafc;padding:10px 12px;text-align:left;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;}
tbody td{padding:10px 12px;border-bottom:1px solid #f1f5f9;vertical-align:middle;}
tfoot td{padding:12px;font-weight:700;}
input[type=number].price{width:130px;text-align:right;}
@media(max-width:600px){
.body{padding:20px;}
thead th{background:#f8fafc;padding:11px 16px;text-align:left;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;}
tbody td{padding:12px 16px;border-bottom:1px solid #f1f5f9;vertical-align:middle;}
tfoot td{padding:13px 16px;font-weight:700;}
input[type=number].price{width:140px;text-align:right;}
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;}
/* ── Desktop: centered card ── */
@media(min-width:641px){
body{padding:28px 16px;}
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.08);max-width:980px;margin:0 auto;overflow:hidden;}
.hd{background:linear-gradient(135deg,#2563eb,#1d4ed8);padding:28px 36px;}
.body{padding:36px;}
}
/* ── Mobile: full page ── */
@media(max-width:640px){
body{background:#fff;padding:0;}
.card{background:#fff;border-radius:0;box-shadow:none;max-width:100%;}
.hd{background:linear-gradient(135deg,#2563eb,#1d4ed8);padding:22px 18px;}
.body{padding:18px 16px 32px;}
.two-col{grid-template-columns:1fr;}
/* Items table → stacked cards */
table,thead,tbody,tfoot,tr,th,td{display:block;}
thead{display:none;}
tbody td{border:none;padding:4px 0;}
tbody tr{border:1px solid #e2e8f0;border-radius:10px;padding:12px;margin-bottom:10px;}
tbody td{border:none;padding:3px 0;}
tbody tr{border:1px solid #e2e8f0;border-radius:10px;padding:12px 14px;margin-bottom:10px;background:#fff;}
tfoot tr{border-top:2px solid #e2e8f0;padding:12px 0;}
input[type=number].price{width:100%;}
/* Confirm code stacks */
.code-row{flex-direction:column !important;align-items:stretch !important;}
.code-display{display:block;text-align:center;}
}
</style>
</head>
<body>
<div class="card">
{{-- Header --}}
<div class="hd">
<div style="font-size:11px;font-weight:600;color:rgba(255,255,255,.7);text-transform:uppercase;letter-spacing:.06em;">Request for Quotation</div>
<div style="font-size:22px;font-weight:700;color:#fff;margin-top:4px;">{{ $purchaseRequest->request_number }}</div>
@@ -59,21 +78,64 @@
<th>Description</th>
<th>Qty</th>
<th>Unit</th>
<th style="text-align:center;">N/A?</th>
<th style="text-align:center;">VAT?</th>
<th style="text-align:right;">Unit Price (BD)</th>
<th style="text-align:right;">Total (BD)</th>
</tr>
</thead>
<tbody>
@foreach($items as $i => $item)
<tr>
<tr id="row-{{ $i }}">
<td style="color:#94a3b8;font-size:12px;">{{ $i + 1 }}</td>
<td style="font-weight:500;">{{ $item->description }}</td>
<td>{{ rtrim(rtrim(number_format((float)$item->quantity, 3), '0'), '.') }}</td>
<td>
{{-- Display mode --}}
<div id="desc-display-{{ $i }}" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span id="desc-text-{{ $i }}" style="font-weight:500;">{{ $item->description }}</span>
<button type="button" onclick="editDesc({{ $i }})"
title="Edit item name"
style="flex-shrink:0;background:none;border:1px solid #e2e8f0;border-radius:5px;padding:2px 6px;cursor:pointer;color:#64748b;font-size:11px;line-height:1.4;"
onmouseover="this.style.borderColor='#2563eb';this.style.color='#2563eb'"
onmouseout="this.style.borderColor='#e2e8f0';this.style.color='#64748b'">
✎ Edit
</button>
<span id="desc-adj-{{ $i }}" style="display:none;font-size:10px;font-weight:700;background:#fef3c7;color:#92400e;padding:1px 6px;border-radius:4px;border:1px solid #fde68a;">adjusted</span>
</div>
{{-- Edit mode --}}
<div id="desc-edit-{{ $i }}" style="display:none;align-items:center;gap:6px;">
<input type="text" id="desc-input-{{ $i }}"
value="{{ $item->description }}"
style="flex:1;min-width:0;padding:5px 8px;border:1.5px solid #2563eb;border-radius:6px;font-size:13px;font-weight:500;outline:none;width:auto;">
<button type="button" onclick="saveDesc({{ $i }}, {{ json_encode($item->description) }})"
style="flex-shrink:0;background:#2563eb;color:#fff;border:none;border-radius:5px;padding:4px 8px;cursor:pointer;font-size:12px;font-weight:700;">✓</button>
<button type="button" onclick="cancelDesc({{ $i }})"
style="flex-shrink:0;background:#f1f5f9;color:#64748b;border:1px solid #e2e8f0;border-radius:5px;padding:4px 8px;cursor:pointer;font-size:12px;">✕</button>
</div>
{{-- Hidden field submitted with form --}}
<input type="hidden" name="items[{{ $i }}][supplier_description]" id="desc-hidden-{{ $i }}" value="">
</td>
<td>{{ rtrim(rtrim(number_format((float)$item->quantity_required, 3), '0'), '.') }}</td>
<td style="color:#64748b;">{{ $item->unit ?: '—' }}</td>
{{-- N/A checkbox --}}
<td style="text-align:center;">
<input type="checkbox" name="items[{{ $i }}][not_available]" id="na-{{ $i }}" value="1"
onchange="toggleNA({{ $i }}, {{ (float)$item->quantity_required }})"
style="width:16px;height:16px;accent-color:#dc2626;cursor:pointer;">
</td>
{{-- VAT checkbox --}}
<td style="text-align:center;">
@if($vatRate > 0)
<input type="checkbox" name="items[{{ $i }}][is_vatable]" id="vat-cb-{{ $i }}" value="1"
onchange="calcRow({{ $i }}, {{ (float)$item->quantity_required }})"
style="width:16px;height:16px;accent-color:#2563eb;cursor:pointer;">
@else
<span style="color:#cbd5e1;font-size:11px;">—</span>
@endif
</td>
<td style="text-align:right;">
<input type="number" class="price" name="items[{{ $i }}][unit_price]"
<input type="number" class="price" id="price-{{ $i }}" name="items[{{ $i }}][unit_price]"
min="0" step="0.001" required placeholder="0.000"
oninput="calcRow({{ $i }}, {{ (float)$item->quantity }})">
oninput="calcRow({{ $i }}, {{ (float)$item->quantity_required }})">
</td>
<td style="text-align:right;font-weight:600;" id="tot-{{ $i }}">—</td>
</tr>
@@ -81,17 +143,25 @@
</tbody>
<tfoot>
<tr style="background:#f8fafc;">
<td colspan="5" style="text-align:right;font-size:13px;color:#475569;">Grand Total:</td>
<td style="text-align:right;font-size:15px;color:#2563eb;" id="grand-total">BD 0.000</td>
<td colspan="7" style="text-align:right;font-size:13px;color:#475569;">Subtotal:</td>
<td style="text-align:right;font-size:14px;color:#475569;" id="subtotal-row">BD 0.000</td>
</tr>
<tr id="vat-tr" style="background:#eef2fb;{{ $vatRate > 0 ? '' : 'display:none;' }}">
<td colspan="7" style="text-align:right;font-size:13px;color:#3b5ea6;">VAT ({{ $vatRate }}%):</td>
<td style="text-align:right;font-size:14px;color:#3b5ea6;" id="vat-row">BD 0.000</td>
</tr>
<tr style="background:#f8fafc;border-top:2px solid #e2e8f0;">
<td colspan="7" style="text-align:right;font-size:13px;color:#475569;font-weight:700;">Grand Total:</td>
<td style="text-align:right;font-size:15px;color:#2563eb;font-weight:700;" id="grand-total">BD 0.000</td>
</tr>
</tfoot>
</table>
</div>
{{-- Terms --}}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;">
{{-- Delivery / Payment --}}
<div class="two-col">
<div>
<label class="fl">Lead Time (days)</label>
<label class="fl">Delivery Time (days)</label>
<input type="number" name="lead_time_days" min="0" placeholder="e.g. 14">
</div>
<div>
@@ -99,32 +169,203 @@
<input type="text" name="payment_terms" placeholder="e.g. 30 days net">
</div>
</div>
<div>
<div style="margin-bottom:20px;">
<label class="fl">Notes / Remarks</label>
<textarea name="notes" rows="3" placeholder="Any additional notes, conditions, or remarks…"></textarea>
</div>
<button class="btn" type="submit">Submit My Quote →</button>
{{-- T&C --}}
<div style="background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:10px;padding:16px 18px;margin-bottom:16px;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Terms &amp; Conditions</div>
<ul style="list-style:none;padding:0;margin:0 0 14px;display:flex;flex-direction:column;gap:7px;">
<li style="display:flex;align-items:flex-start;gap:9px;font-size:12px;color:#475569;line-height:1.5;">
<span style="flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#dbeafe;display:flex;align-items:center;justify-content:center;margin-top:1px;">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none"><path d="M2 5.5L4 7.5L8 3" stroke="#2563eb" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
Prices stated in this quote are valid for <strong>30 days</strong> from the submission date.
</li>
<li style="display:flex;align-items:flex-start;gap:9px;font-size:12px;color:#475569;line-height:1.5;">
<span style="flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#dbeafe;display:flex;align-items:center;justify-content:center;margin-top:1px;">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none"><path d="M2 5.5L4 7.5L8 3" stroke="#2563eb" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
Delivery will be made within the specified delivery time stated above.
</li>
<li style="display:flex;align-items:flex-start;gap:9px;font-size:12px;color:#475569;line-height:1.5;">
<span style="flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#dbeafe;display:flex;align-items:center;justify-content:center;margin-top:1px;">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none"><path d="M2 5.5L4 7.5L8 3" stroke="#2563eb" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
All items supplied will meet the required specifications and quality standards.
</li>
<li style="display:flex;align-items:flex-start;gap:9px;font-size:12px;color:#475569;line-height:1.5;">
<span style="flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#dbeafe;display:flex;align-items:center;justify-content:center;margin-top:1px;">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none"><path d="M2 5.5L4 7.5L8 3" stroke="#2563eb" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
This quote is <strong>binding upon acceptance</strong> and constitutes a formal offer.
</li>
</ul>
<label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;padding:10px 12px;background:#fff;border:1.5px solid #e2e8f0;border-radius:8px;">
<input type="checkbox" name="terms" id="terms-cb" value="1" required
onchange="checkReady()"
style="width:16px;height:16px;margin-top:1px;accent-color:#2563eb;flex-shrink:0;cursor:pointer;">
<span style="font-size:13px;font-weight:600;color:#0f172a;">I have read and agree to the terms and conditions above</span>
</label>
@error('terms')
<div style="font-size:12px;color:#dc2626;margin-top:6px;">{{ $message }}</div>
@enderror
</div>
{{-- Confirmation code --}}
<div style="background:#fffbeb;border:1.5px solid #fde68a;border-radius:10px;padding:16px 18px;margin-bottom:20px;">
<div style="font-size:11px;font-weight:700;color:#92400e;text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;">Confirmation Required</div>
<div class="code-row" style="display:flex;align-items:center;gap:14px;">
<div style="flex-shrink:0;">
<div style="font-size:11px;color:#92400e;margin-bottom:6px;">Copy this code:</div>
<div class="code-display"
style="font-size:24px;font-weight:800;letter-spacing:.2em;color:#92400e;
background:#fef3c7;border:2px dashed #f59e0b;border-radius:8px;
padding:10px 20px;font-family:monospace;user-select:all;white-space:nowrap;">
{{ $confirmCode }}
</div>
</div>
<div style="flex:1;min-width:0;">
<label class="fl" style="color:#92400e;">Paste code here</label>
<input type="text" name="confirm_code" id="confirm-input"
autocomplete="off" autocorrect="off" autocapitalize="characters" spellcheck="false"
placeholder="Paste the code above"
oninput="checkReady()"
style="padding:11px 12px;border:1.5px solid #fde68a;
font-size:16px;font-weight:700;letter-spacing:.12em;font-family:monospace;
text-transform:uppercase;outline:none;background:#fff;">
@error('confirm_code')
<div style="font-size:12px;color:#dc2626;margin-top:4px;">{{ $message }}</div>
@enderror
</div>
</div>
</div>
<button class="btn" type="submit" id="submit-btn" disabled style="opacity:.4;cursor:not-allowed;">
Submit My Quote →
</button>
</form>
<p style="font-size:11px;color:#cbd5e1;margin-top:16px;text-align:center;">
<p style="font-size:11px;color:#cbd5e1;margin-top:18px;text-align:center;">
Link expires {{ $invitation->expires_at->format('d M Y') }} · One submission only
</p>
</div>
</div>
<script>
var _totals = {};
var _vatRate = {{ $vatRate }};
var _totals = {};
var _vatable = {};
var _navail = {};
// ── Description inline edit ──────────────────────────────
function editDesc(i) {
document.getElementById('desc-display-' + i).style.display = 'none';
var ed = document.getElementById('desc-edit-' + i);
ed.style.display = 'flex';
document.getElementById('desc-input-' + i).focus();
}
function saveDesc(i, original) {
var val = document.getElementById('desc-input-' + i).value.trim();
if (!val) val = original;
document.getElementById('desc-text-' + i).textContent = val;
document.getElementById('desc-hidden-' + i).value = (val !== original) ? val : '';
var adj = document.getElementById('desc-adj-' + i);
adj.style.display = (val !== original) ? 'inline-block' : 'none';
document.getElementById('desc-edit-' + i).style.display = 'none';
document.getElementById('desc-display-' + i).style.display = 'flex';
}
function cancelDesc(i) {
var original = document.getElementById('desc-text-' + i).textContent;
document.getElementById('desc-input-' + i).value = original;
document.getElementById('desc-edit-' + i).style.display = 'none';
document.getElementById('desc-display-' + i).style.display = 'flex';
}
// ── N/A toggle ───────────────────────────────────────────
function toggleNA(i, qty) {
var naCb = document.getElementById('na-' + i);
var priceIn = document.getElementById('price-' + i);
var vatCb = document.getElementById('vat-cb-' + i);
var totEl = document.getElementById('tot-' + i);
var row = document.getElementById('row-' + i);
var na = naCb.checked;
_navail[i] = na;
if (na) {
priceIn.value = '';
priceIn.disabled = true;
priceIn.removeAttribute('required');
priceIn.style.opacity = '.35';
if (vatCb) { vatCb.checked = false; vatCb.disabled = true; vatCb.style.opacity = '.35'; }
totEl.innerHTML = '<span style="font-size:11px;font-weight:700;color:#dc2626;background:#fef2f2;padding:2px 7px;border-radius:4px;">Not available</span>';
row.style.opacity = '.6';
_totals[i] = 0;
_vatable[i] = false;
} else {
priceIn.disabled = false;
priceIn.setAttribute('required', '');
priceIn.style.opacity = '1';
if (vatCb) { vatCb.disabled = false; vatCb.style.opacity = '1'; }
totEl.textContent = '—';
row.style.opacity = '1';
_totals[i] = 0;
_vatable[i] = false;
calcRow(i, qty);
return;
}
recalcTotals();
}
// ── Price / VAT calculation ──────────────────────────────
function calcRow(i, qty) {
var inp = document.querySelector('input[name="items[' + i + '][unit_price]"]');
if (_navail[i]) return;
var inp = document.getElementById('price-' + i);
var cb = document.getElementById('vat-cb-' + i);
var up = parseFloat(inp ? inp.value : 0) || 0;
var tot = Math.round(up * qty * 1000) / 1000;
_totals[i] = tot;
_totals[i] = tot;
_vatable[i] = cb ? cb.checked : false;
var el = document.getElementById('tot-' + i);
if (el) el.textContent = 'BD ' + tot.toFixed(3);
var grand = Object.values(_totals).reduce(function(a,b){return a+b;}, 0);
var ge = document.getElementById('grand-total');
if (ge) ge.textContent = 'BD ' + grand.toFixed(3);
if (el) el.textContent = tot > 0 ? 'BD ' + tot.toFixed(3) : '—';
recalcTotals();
}
function recalcTotals() {
var subtotal = 0;
var vatAmount = 0;
Object.keys(_totals).forEach(function(i) {
if (_navail[i]) return;
subtotal += _totals[i];
if (_vatable[i] && _vatRate > 0) {
vatAmount += Math.round(_totals[i] * _vatRate / 100 * 1000) / 1000;
}
});
var grand = Math.round((subtotal + vatAmount) * 1000) / 1000;
var elSub = document.getElementById('subtotal-row');
var elVat = document.getElementById('vat-row');
var elGrand = document.getElementById('grand-total');
if (elSub) elSub.textContent = 'BD ' + subtotal.toFixed(3);
if (elVat) elVat.textContent = 'BD ' + vatAmount.toFixed(3);
if (elGrand) elGrand.textContent = 'BD ' + grand.toFixed(3);
}
var _expected = '{{ $confirmCode }}';
function checkReady() {
var terms = document.getElementById('terms-cb');
var inp = document.getElementById('confirm-input');
var btn = document.getElementById('submit-btn');
if (!terms || !inp || !btn) return;
var codeOk = inp.value.trim().toUpperCase() === _expected;
var ok = terms.checked && codeOk;
btn.disabled = !ok;
btn.style.opacity = ok ? '1' : '.4';
btn.style.cursor = ok ? 'pointer' : 'not-allowed';
inp.style.borderColor = inp.value.length > 0 ? (codeOk ? '#16a34a' : '#ef4444') : '#fde68a';
}
</script>
</body>
+76 -12
View File
@@ -3,25 +3,89 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Quote Submitted</title>
<title>Quote Submitted — Thank You</title>
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:system-ui,-apple-system,sans-serif;background:#f0fdf4;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;}
.card{background:#fff;border-radius:20px;box-shadow:0 8px 32px rgba(0,0,0,.10);padding:52px 44px;max-width:480px;width:100%;text-align:center;}
body{font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}
@keyframes popIn{from{opacity:0;transform:scale(.88) translateY(18px);}to{opacity:1;transform:scale(1) translateY(0);}}
@keyframes checkDraw{from{stroke-dashoffset:60;}to{stroke-dashoffset:0;}}
@keyframes ringPulse{0%,100%{transform:scale(1);opacity:.3;}50%{transform:scale(1.2);opacity:.1;}}
.detail-row{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:13px;padding:8px 0;}
/* ── Desktop: centered card ── */
@media(min-width:641px){
body{background:linear-gradient(135deg,#f0fdf4 0%,#ecfdf5 50%,#d1fae5 100%);display:flex;align-items:center;justify-content:center;padding:28px 16px;}
.card{background:#fff;border-radius:24px;box-shadow:0 20px 60px rgba(0,0,0,.10),0 4px 16px rgba(0,0,0,.06);max-width:500px;width:100%;overflow:hidden;animation:popIn .5s cubic-bezier(.34,1.56,.64,1) forwards;}
.inner{padding:44px 40px 36px;text-align:center;}
.footer-bar{padding:14px 40px;background:#f8fafc;border-top:1px solid #f1f5f9;text-align:center;}
}
/* ── Mobile: full page ── */
@media(max-width:640px){
body{background:#fff;display:flex;flex-direction:column;}
.card{background:#fff;border-radius:0;box-shadow:none;width:100%;flex:1;display:flex;flex-direction:column;}
.inner{padding:36px 20px 28px;text-align:center;flex:1;}
.footer-bar{padding:14px 20px;background:#f8fafc;border-top:1px solid #f1f5f9;text-align:center;}
.icon-wrap{margin-bottom:20px !important;}
}
</style>
</head>
<body>
<div class="card">
<div style="font-size:60px;margin-bottom:20px;">✅</div>
<div style="font-size:24px;font-weight:700;color:#15803d;margin-bottom:10px;">Quote Submitted!</div>
<div style="font-size:14px;color:#475569;line-height:1.65;">
Thank you, <strong>{{ $invitation->supplier->name }}</strong>. Your quote for
<strong>{{ $invitation->purchaseRequest->request_number }}</strong> has been received
and is under review. You will be contacted if you are selected.
<div class="card">
<div style="height:6px;background:linear-gradient(90deg,#16a34a,#22c55e,#4ade80);"></div>
<div class="inner">
{{-- Animated check --}}
<div class="icon-wrap" style="position:relative;display:inline-block;margin-bottom:28px;">
<div style="position:absolute;inset:-12px;border-radius:50%;background:#dcfce7;animation:ringPulse 2.5s ease-in-out infinite;"></div>
<div style="width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,#16a34a,#22c55e);display:flex;align-items:center;justify-content:center;position:relative;box-shadow:0 8px 24px rgba(22,163,74,.3);">
<svg width="38" height="38" viewBox="0 0 38 38" fill="none">
<path d="M10 19.5L16 25.5L28 13" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
style="stroke-dasharray:60;stroke-dashoffset:60;animation:checkDraw .6s .35s ease forwards;"/>
</svg>
</div>
</div>
<div style="margin-top:28px;padding:14px;background:#f0fdf4;border-radius:10px;font-size:12px;color:#64748b;">
Submitted on {{ now()->format('d M Y, H:i') }}
<div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#16a34a;margin-bottom:8px;">Quote Received</div>
<h1 style="font-size:26px;font-weight:800;color:#0f172a;margin-bottom:14px;line-height:1.25;">
Thank you,<br>{{ $invitation->supplier->name }}!
</h1>
<p style="font-size:15px;color:#475569;line-height:1.75;max-width:360px;margin:0 auto 24px;">
Your quote for <strong style="color:#0f172a;">{{ $invitation->purchaseRequest->request_number }}</strong>
has been successfully received. Our team will review all submitted quotes and get back to you shortly.
</p>
{{-- Details --}}
<div style="background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:12px;padding:4px 20px;text-align:left;margin-bottom:24px;">
<div class="detail-row">
<span style="color:#64748b;">Reference</span>
<span style="font-weight:700;color:#0f172a;">{{ $invitation->purchaseRequest->request_number }}</span>
</div>
<div class="detail-row" style="border-top:1px solid #f1f5f9;">
<span style="color:#64748b;">Submitted</span>
<span style="font-weight:600;color:#0f172a;">{{ now()->format('d M Y, H:i') }}</span>
</div>
@if($invitation->purchaseRequest->project_name)
<div class="detail-row" style="border-top:1px solid #f1f5f9;">
<span style="color:#64748b;">Project</span>
<span style="font-weight:600;color:#0f172a;">{{ $invitation->purchaseRequest->project_name }}</span>
</div>
@endif
</div>
<p style="font-size:13px;color:#94a3b8;line-height:1.65;">
This link has now been closed. No further action is required.<br>
We appreciate your time and look forward to working with you.
</p>
</div>
<div class="footer-bar">
<span style="font-size:11px;color:#cbd5e1;font-weight:500;letter-spacing:.03em;">SteelERP · Procurement Portal</span>
</div>
</div>
</body>
</html>
File diff suppressed because it is too large. Load diff
+59
View File
@@ -0,0 +1,59 @@
@extends('layouts.app')
@section('title', 'Settings — VAT')
@section('content')
<div class="mb-5">
<h1 class="page-title">VAT Settings</h1>
<p class="page-subtitle">Set the global VAT rate applied to vatable items on supplier quotes.</p>
</div>
<div style="max-width:480px;">
<div style="background:white;border:1px solid #e2e8f0;border-radius:0.875rem;overflow:hidden;">
<div style="padding:1.25rem 1.5rem;border-bottom:1px solid #e2e8f0;background:#f8fafc;">
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;">VAT Configuration</div>
</div>
<div style="padding:1.5rem;">
<label style="display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:6px;">
VAT Rate (%)
</label>
<div style="display:flex;align-items:center;gap:10px;">
<input type="number" id="vat-rate-input" value="{{ $vatRate }}"
min="0" max="100" step="0.01" placeholder="e.g. 10"
style="width:160px;padding:9px 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:14px;font-weight:600;outline:none;"
onfocus="this.style.borderColor='#2563eb'" onblur="this.style.borderColor='#e2e8f0'">
<span style="font-size:14px;color:#64748b;font-weight:500;">%</span>
</div>
<p style="font-size:12px;color:#94a3b8;margin-top:8px;">
Enter 0 to disable VAT. Suppliers will see a VAT checkbox on each item when this is greater than 0.
</p>
<button onclick="saveVat()"
style="margin-top:20px;padding:10px 24px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;">
Save VAT Rate
</button>
</div>
</div>
</div>
<script>
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
function saveVat() {
var rate = document.getElementById('vat-rate-input').value;
fetch('{{ route('settings.vat.update') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ vat_rate: rate })
}).then(function(r) {
return r.json().then(function(body) {
if (!r.ok) return Promise.reject(body);
return body;
});
}).then(function() {
showToast('VAT rate saved.', 'success');
}).catch(function(err) {
var msg = (err.errors && err.errors.vat_rate) ? err.errors.vat_rate[0] : (err.message || 'Failed to save.');
showToast(msg, 'error');
});
}
</script>
@endsection
+34
View File
@@ -29,6 +29,7 @@ use App\Http\Controllers\Sales\SalesOrderController;
use App\Http\Controllers\MailAccountController;
use App\Http\Controllers\SettingsController;
use App\Http\Controllers\Settings\ProjectSettingController;
use App\Http\Controllers\Settings\VatSettingController;
use App\Models\Settings\Location;
use Illuminate\Support\Facades\Route;
@@ -43,6 +44,27 @@ Route::post('/rfq/{token}', [RfqPortalController::class, 'submit'])->name('rfq.s
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::get('/notifications/unread', fn() => response()->json([
'count' => auth()->user()->unreadNotifications()->count(),
'items' => auth()->user()->unreadNotifications()->latest()->take(10)->get()->map(fn($n) => [
'id' => $n->id,
'message' => $n->data['message'] ?? '',
'go_url' => route('notifications.go', $n->id),
'ago' => $n->created_at->diffForHumans(),
]),
]))->name('notifications.unread');
Route::get('/notifications/{id}/go', function (string $id) {
$n = auth()->user()->notifications()->findOrFail($id);
$n->markAsRead();
$dest = $n->data['url'] ?? route('dashboard');
return redirect($dest);
})->name('notifications.go');
Route::post('/notifications/read-all', fn() => response()->json(
tap(auth()->user()->unreadNotifications()->update(['read_at' => now()]))
))->name('notifications.read-all');
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
@@ -136,11 +158,23 @@ Route::middleware(['auth', 'verified'])->group(function () {
// Projects settings
Route::get('settings/projects', [ProjectSettingController::class, 'index'])->name('settings.projects.index');
Route::post('settings/projects', [ProjectSettingController::class, 'store'])->name('settings.projects.store');
Route::post('settings/projects/import', [ProjectSettingController::class, 'import'])->name('settings.projects.import');
Route::get('settings/projects/template', [ProjectSettingController::class, 'downloadTemplate'])->name('settings.projects.template');
Route::post('settings/projects/companies', [ProjectSettingController::class, 'storeCompany'])->name('settings.projects.companies.store');
Route::patch('settings/projects/companies/{company}', [ProjectSettingController::class, 'updateCompany'])->name('settings.projects.companies.update');
Route::delete('settings/projects/companies/{company}', [ProjectSettingController::class, 'destroyCompany'])->name('settings.projects.companies.destroy');
Route::patch('settings/projects/{project}', [ProjectSettingController::class, 'update'])->name('settings.projects.update');
Route::delete('settings/projects/{project}', [ProjectSettingController::class, 'destroy'])->name('settings.projects.destroy');
Route::post('settings/projects/{project}/locations', [ProjectSettingController::class, 'storeLocation'])->name('settings.projects.locations.store');
Route::patch('settings/projects/{project}/locations/{location}', [ProjectSettingController::class, 'updateLocation'])->name('settings.projects.locations.update');
Route::delete('settings/projects/{project}/locations/{location}', [ProjectSettingController::class, 'destroyLocation'])->name('settings.projects.locations.destroy');
Route::post('settings/projects/companies/{company}/departments', [ProjectSettingController::class, 'storeDepartment'])->name('settings.projects.companies.departments.store');
Route::patch('settings/projects/companies/{company}/departments/{department}', [ProjectSettingController::class, 'updateDepartment'])->name('settings.projects.companies.departments.update');
Route::delete('settings/projects/companies/{company}/departments/{department}', [ProjectSettingController::class, 'destroyDepartment'])->name('settings.projects.companies.departments.destroy');
// VAT settings
Route::get('settings/vat', [VatSettingController::class, 'index'])->name('settings.vat');
Route::post('settings/vat', [VatSettingController::class, 'update'])->name('settings.vat.update');
});
});