Compare commits
23
Commits
c6a12163bb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c4c752800 | ||
|
|
30b2fb3958 | ||
|
|
50e5f3e381 | ||
|
|
d28e7330e6 | ||
|
|
716f0afcac | ||
|
|
6061e8ca4f | ||
|
|
7b399d5167 | ||
|
|
ee89269c0b | ||
|
|
a9db089a90 | ||
|
|
04ced4d2da | ||
|
|
b19396c996 | ||
|
|
3252cf7fb8 | ||
|
|
dca9cd5d99 | ||
|
|
72e6c3170e | ||
|
|
f2c0f22156 | ||
|
|
12e07480a0 | ||
|
|
9a268dc79b | ||
|
|
a425e12349 | ||
|
|
60d57af630 | ||
|
|
58b3e9e0de | ||
|
|
b1986e8654 | ||
|
|
a43805e30c | ||
|
|
6955d7b422 |
No files matched your search
@@ -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 & 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']]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
};
|
||||
+41
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+26
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
/vendor/
|
||||
composer.lock
|
||||
*.cache
|
||||
@@ -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
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/vendor/
|
||||
composer.lock
|
||||
@@ -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),
|
||||
];
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;">▼</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;">▼</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;">▼</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\'">×</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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
}());
|
||||
</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
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user