diff --git a/.gitignore b/.gitignore index 9491a2f..9cf486b 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,6 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd +# Hermes Agent workspace +.hermes/ diff --git a/IMS.API/Attributes/ApiKeyAuthorizeAttribute.cs b/IMS.API/Attributes/ApiKeyAuthorizeAttribute.cs new file mode 100644 index 0000000..5da38bb --- /dev/null +++ b/IMS.API/Attributes/ApiKeyAuthorizeAttribute.cs @@ -0,0 +1,40 @@ +using IMS.Infrastructure.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.EntityFrameworkCore; + +namespace IMS.API.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class ApiKeyAuthorizeAttribute : Attribute, IAsyncActionFilter +{ + private const string HeaderName = "X-API-Key"; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var dbContext = context.HttpContext.RequestServices.GetService(); + if (dbContext == null) + { + context.Result = new StatusCodeResult(StatusCodes.Status500InternalServerError); + return; + } + + if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out var keyValue) || + string.IsNullOrWhiteSpace(keyValue)) + { + context.Result = new UnauthorizedResult(); + return; + } + + var apiKey = await dbContext.ApiKeys + .FirstOrDefaultAsync(k => k.Key == keyValue.ToString() && k.IsActive); + + if (apiKey is null || apiKey.ExpiresAt <= DateTime.UtcNow) + { + context.Result = new UnauthorizedResult(); + return; + } + + await next(); + } +} diff --git a/IMS.API/Controllers/ApiKeysController.cs b/IMS.API/Controllers/ApiKeysController.cs new file mode 100644 index 0000000..5356a09 --- /dev/null +++ b/IMS.API/Controllers/ApiKeysController.cs @@ -0,0 +1,97 @@ +using IMS.API.Attributes; +using IMS.Core.Entities; +using IMS.Infrastructure.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace IMS.API.Controllers; + +[ApiController] +[Route("api/apikeys")] +public class ApiKeysController : ControllerBase +{ + private readonly ApplicationDbContext _db; + private readonly ILogger _logger; + + public ApiKeysController(ApplicationDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + [HttpPost("create")] + [ApiKeyAuthorize] + public async Task Create([FromBody] CreateApiKeyRequest request) + { + if (!Request.Headers.TryGetValue("X-API-Key", out var headerKey)) + { + return Unauthorized(); + } + + var adminKey = await _db.ApiKeys + .FirstOrDefaultAsync(k => k.Key == headerKey.ToString() && k.IsActive); + + if (adminKey is null || adminKey.ExpiresAt <= DateTime.UtcNow) + { + return Unauthorized(); + } + + if (request.ExpiresAt.Kind != DateTimeKind.Utc) + { + request.ExpiresAt = request.ExpiresAt.ToUniversalTime(); + } + + var newKey = new ApiKey + { + Key = new string(Path.GetRandomFileName().Replace(".", "").Take(32).ToArray()), + Owner = string.IsNullOrWhiteSpace(request.Owner) ? null : request.Owner.Trim(), + ExpiresAt = request.ExpiresAt + }; + + _db.ApiKeys.Add(newKey); + await _db.SaveChangesAsync(); + + return Ok(new ApiKeyResponse + { + Id = newKey.Id, + Key = newKey.Key, + Owner = newKey.Owner, + ExpiresAt = newKey.ExpiresAt, + IsActive = newKey.IsActive + }); + } + + [HttpGet] + [ApiKeyAuthorize] + public async Task GetAll() + { + var keys = await _db.ApiKeys + .OrderByDescending(k => k.CreatedAt) + .Select(k => new ApiKeyResponse + { + Id = k.Id, + Key = k.Key.Length > 6 ? k.Key.Substring(0, 6) + "..." : k.Key, + Owner = k.Owner, + ExpiresAt = k.ExpiresAt, + IsActive = k.IsActive + }) + .ToListAsync(); + + return Ok(keys); + } +} + +public class CreateApiKeyRequest +{ + public string? Owner { get; set; } + public DateTime ExpiresAt { get; set; } +} + +public class ApiKeyResponse +{ + public int Id { get; set; } + public string Key { get; set; } = string.Empty; + public string? Owner { get; set; } + public DateTime ExpiresAt { get; set; } + public bool IsActive { get; set; } +} diff --git a/IMS.API/Controllers/TestController.cs b/IMS.API/Controllers/TestController.cs new file mode 100644 index 0000000..83f5420 --- /dev/null +++ b/IMS.API/Controllers/TestController.cs @@ -0,0 +1,16 @@ +using IMS.API.Attributes; +using Microsoft.AspNetCore.Mvc; + +namespace IMS.API.Controllers; + +[ApiController] +[Route("api/test")] +public class TestController : ControllerBase +{ + [HttpGet("protected")] + [ApiKeyAuthorize] + public IActionResult Protected() + { + return Ok(new { Message = "You accessed a protected endpoint with a valid API key." }); + } +} diff --git a/IMS.API/Program.cs b/IMS.API/Program.cs index f9ad3bf..4ce1e57 100644 --- a/IMS.API/Program.cs +++ b/IMS.API/Program.cs @@ -1,3 +1,4 @@ +using IMS.API.Attributes; using IMS.API.Services; using IMS.Core.Contracts; using IMS.Core.Entities; @@ -99,23 +100,23 @@ app.UseSwaggerUI(); } -app.UseHttpsRedirection(); +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} +app.UseStaticFiles(); app.UseCors("AllowAll"); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); - - - +app.MapFallbackToFile("index.html"); using (var seedScope = app.Services.CreateScope()) { - var dbContext = seedScope.ServiceProvider.GetRequiredService(); await RoleSeeder.SeedRolesAsync(dbContext, seedScope.ServiceProvider); await DataSeeder.SeedDataAsync(seedScope.ServiceProvider); } - app.Run(); diff --git a/IMS.API/appsettings.json b/IMS.API/appsettings.json index e0413ea..605cb5e 100644 --- a/IMS.API/appsettings.json +++ b/IMS.API/appsettings.json @@ -8,11 +8,11 @@ "AllowedHosts": "*", "ConnectionStrings": { - "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=IMS_DB;Trusted_Connection=True;TrustServerCertificate=True" + "DefaultConnection": "Server=db63575.public.databaseasp.net;Database=db63575;User Id=db63575;Password=__MONSTERASP_ENV__;Encrypt=False;MultipleActiveResultSets=True;TrustServerCertificate=True;" }, - "BaseUrl": "https://localhost:7086", + "BaseUrl": "http://imcapp.runasp.net", "JwtOptions": { - "Issuer": "https://localhost:7086", + "Issuer": "http://imcapp.runasp.net", "Audience": "InventoryManagementApi", "SecretKey": "YourSuperSecretKey123456789012345678901234567890", "ExpiryMinutes": 60 diff --git a/IMS.API/wwwroot/css/styles.css b/IMS.API/wwwroot/css/styles.css new file mode 100644 index 0000000..ca9495b --- /dev/null +++ b/IMS.API/wwwroot/css/styles.css @@ -0,0 +1,159 @@ +body { + font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, sans-serif; + margin: 0; + padding: 18px; + background: #f7f8fa; + color: #1f2937; +} + +a { + color: inherit; + text-decoration: none; +} + +.hidden { + display: none !important; +} + +.header { + background: #0f172a; + color: #fff; + padding: 18px 28px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.header .brand { + font-weight: 700; + font-size: 18px; +} + +.header .user { + font-size: 14px; + opacity: 0.9; +} + +.container { + max-width: 1100px; + margin: 28px auto; + padding: 0 18px; +} + +.card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 14px; + padding: 18px; + margin-bottom: 16px; +} + +.title { + font-size: 22px; + font-weight: 700; + margin-bottom: 14px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 18px; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: none; + padding: 10px 14px; + border-radius: 10px; + cursor: pointer; + font-weight: 600; + color: #fff; + background: #2563eb; +} + +.btn.secondary { + background: #6b7280; +} +.btn.success { + background: #16a34a; +} +.btn.danger { + background: #dc2626; +} +.btn.ghost { + background: #e5e7eb; + color: #111827; +} + +input, select, textarea { + width: 100%; + padding: 9px 10px; + border-radius: 10px; + border: 1px solid #d1d5db; + background: #fff; + font: inherit; +} + +label { + display: block; + font-size: 13px; + font-weight: 700; + color: #374151; + margin: 10px 0 6px; +} + +.form-grid > div { + margin-bottom: 14px; + padding: 0 6px; +} +} + +.table { + width: 100%; + border-collapse: collapse; + margin-top: 12px; + background: #fff; + border-radius: 12px; + overflow: hidden; + border: 1px solid #e5e7eb; +} + +.table th, .table td { + padding: 11px 13px; + text-align: left; + border-bottom: 1px solid #f3f4f6; + font-size: 14px; +} + +.table th { + background: #f9fafb; + font-weight: 700; + color: #374151; +} + +.login { + max-width: 380px; + margin: 80px auto; + padding: 22px; +} + +.status { + margin-top: 10px; + font-size: 14px; + color: #dc2626; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.actions-right { + margin-left: auto; +} diff --git a/IMS.API/wwwroot/index.html b/IMS.API/wwwroot/index.html new file mode 100644 index 0000000..a916c77 --- /dev/null +++ b/IMS.API/wwwroot/index.html @@ -0,0 +1,14 @@ + + + + + + IMS Web + + + +
+ + + + diff --git a/IMS.API/wwwroot/js/app.js b/IMS.API/wwwroot/js/app.js new file mode 100644 index 0000000..0c9420c --- /dev/null +++ b/IMS.API/wwwroot/js/app.js @@ -0,0 +1,710 @@ +(() => { + const $ = (sel) => document.querySelector(sel); + const on = (el, ev, fn) => el.addEventListener(ev, fn); + const canManage = () => state.role === 'Admin' || state.role === 'Manager'; + + const state = { + email: localStorage.getItem(IMS_API.EMAIL_KEY) || '', + token: localStorage.getItem(IMS_API.TOKEN_KEY) || '', + role: localStorage.getItem(IMS_API.ROLE_KEY) || '', + products: [], + categories: [], + transactions: [], + editingCategoryId: null, + editingProductId: null + }; + + const authHeaders = () => ({ + 'Content-Type': 'application/json', + ...(state.token ? { Authorization: `Bearer ${state.token}` } : {}) + }); + + const api = async (path, opts = {}) => { + const res = await fetch(IMS_API.BASE_URL + path, { + ...opts, + headers: { + ...authHeaders(), + ...(opts.headers || {}) + } + }); + + if (res.status === 401) { + logout(); + throw new Error('Unauthorized'); + } + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `HTTP ${res.status}`); + } + + if (res.status === 204) return null; + + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('application/json')) return res.json(); + return res.text(); + }; + + const setAuth = (data) => { + state.token = data.token; + state.email = data.email; + state.role = (data.roles || [])[0] || ''; + localStorage.setItem(IMS_API.TOKEN_KEY, data.token); + localStorage.setItem(IMS_API.EMAIL_KEY, data.email); + localStorage.setItem(IMS_API.ROLE_KEY, state.role); + }; + + const logout = () => { + state.token = ''; + state.email = ''; + state.role = ''; + localStorage.removeItem(IMS_API.TOKEN_KEY); + localStorage.removeItem(IMS_API.EMAIL_KEY); + localStorage.removeItem(IMS_API.ROLE_KEY); + }; + + const show = (id) => { + document.querySelectorAll('#app > section').forEach((el) => el.classList.add('hidden')); + const target = document.getElementById(id); + if (target) target.classList.remove('hidden'); + }; + + const esc = (val) => { + if (val == null) return ''; + return String(val) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + }; + + const renderTable = (containerId, rows, columns, actions) => { + const container = document.getElementById(containerId); + if (!container) return; + if (!rows.length) { + container.innerHTML = '
No records yet.
'; + return; + } + + let html = ` +
+ + + + ${columns.map((col) => ``).join('')} + ${actions && canManage() ? '' : ''} + + + + ${rows.map((row) => ` + + ${columns.map((col) => ``).join('')} + ${actions && canManage() ? `` : ''} + + `).join('')} + +
${esc(col.label)}Actions
${esc(col.value ? col.value(row) : row[col.key])}${actions(row)}
+
+ `; + container.innerHTML = html; + }; + + const confirm = (message) => window.confirm(message || 'Are you sure?'); + + const setStatus = (el, message, type = 'error') => { + if (!el) return; + el.textContent = message || ''; + el.className = 'status ' + (type === 'success' ? 'success' : ''); + }; + + const loginPage = () => ` + + `; + + const dashboardPage = () => { + const canManage = state.role === 'Admin' || state.role === 'Manager'; + return ` + + `; + }; + + const productsPage = () => ` + + `; + + const categoriesPage = () => ` + + `; + + const transactionsPage = () => ` + + `; + + const init = () => { + const app = $('#app'); + app.innerHTML = state.token + ? dashboardPage() + categoriesPage() + productsPage() + transactionsPage() + : loginPage(); + + if (state.token) { + initDashboard(); + initProducts(); + initCategories(); + initTransactions(); + show('dashboard'); + } else { + initLogin(); + show('login'); + } + }; + + const initLogin = () => { + on($('#loginBtn'), 'click', async () => { + const email = $('#loginEmail').value.trim(); + const password = $('#loginPassword').value.trim(); + const errorEl = $('#loginError'); + errorEl.textContent = ''; + + if (!email || !password) { + errorEl.textContent = 'Enter email and password.'; + return; + } + + $('#loginBtn').disabled = true; + $('#loginBtn').textContent = 'Logging in...'; + + try { + const data = await api('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }) + }); + + setAuth(data); + init(); + } catch (err) { + errorEl.textContent = err.message || 'Login failed.'; + } finally { + $('#loginBtn').disabled = false; + $('#loginBtn').textContent = 'Login'; + } + }); + }; + + const initDashboard = () => { + on($('#logoutBtn'), 'click', () => { + logout(); + init(); + }); + + document.querySelectorAll('.open-page').forEach((btn) => { + on(btn, 'click', async () => { + const page = btn.dataset.page; + show(page); + if (page === 'products') await loadProducts(); + if (page === 'categories') await loadCategories(); + if (page === 'transactions') await loadTransactions(); + }); + }); + + const doImport = async (file) => { + const form = new FormData(); + form.append('file', file); + const res = await fetch(IMS_API.BASE_URL + '/products/import', { + method: 'POST', + headers: authHeaders(), + body: form + }); + if (!res.ok) throw new Error('Import failed'); + alert('Import completed.'); + }; + + on($('#exportCsv'), 'click', async () => { + const res = await fetch(IMS_API.BASE_URL + '/products/export', { + headers: authHeaders(), + }); + if (!res.ok) throw new Error('Export failed'); + const csv = await res.text(); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'products_export.csv'; + a.click(); + URL.revokeObjectURL(url); + }); + + on($('#importCsv'), 'change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + try { + $('#importCsv').disabled = true; + await doImport(file); + await loadProducts(); + } catch (err) { + alert(err.message); + } finally { + $('#importCsv').disabled = false; + $('#importCsv').value = ''; + } + }); + }; + + const initProducts = () => { + const setProductForm = ({ id, name, price, quantityInStock, supplier, categoryId, description }) => { + $('#pId').value = id || ''; + $('#pName').value = name || ''; + $('#pPrice').value = price ?? ''; + $('#pQty').value = quantityInStock ?? ''; + $('#pSupplier').value = supplier || ''; + $('#pDesc').value = description || ''; + $('#pCategory').value = categoryId || ''; + $('#productFormTitle').textContent = id ? 'Edit Product' : 'Add Product'; + $('#cancelProduct').classList.toggle('hidden', !id); + }; + + const resetProductForm = () => { + state.editingProductId = null; + $('#pId').value = ''; + $('#pName').value = ''; + $('#pPrice').value = ''; + $('#pQty').value = ''; + $('#pSupplier').value = ''; + $('#pDesc').value = ''; + $('#pCategory').value = ''; + $('#productStatus').textContent = ''; + $('#productFormTitle').textContent = 'Add Product'; + $('#cancelProduct').classList.add('hidden'); + }; + + on($('#saveProduct'), 'click', async () => { + const id = $('#pId').value.trim(); + const name = $('#pName').value.trim(); + const price = parseFloat($('#pPrice').value); + const qty = parseInt($('#pQty').value, 10); + const supplier = $('#pSupplier').value.trim(); + const categoryId = parseInt($('#pCategory').value, 10); + const desc = $('#pDesc').value.trim(); + const status = $('#productStatus'); + status.textContent = ''; + + if (!name || Number.isNaN(price) || Number.isNaN(qty) || Number.isNaN(categoryId)) { + status.textContent = 'Fill required product fields.'; + return; + } + + $('#saveProduct').disabled = true; + try { + if (id) { + await api(`/products/${id}`, { + method: 'PUT', + body: JSON.stringify({ id: parseInt(id, 10), name, price, quantityInStock: qty, supplier, categoryId, description: desc }) + }); + setStatus(status, 'Product updated.', 'success'); + state.editingProductId = null; + $('#cancelProduct').classList.add('hidden'); + $('#productFormTitle').textContent = 'Add Product'; + } else { + await api('/products', { + method: 'POST', + body: JSON.stringify({ name, price, quantityInStock: qty, supplier, categoryId, description: desc }) + }); + setStatus(status, 'Product created.', 'success'); + resetProductForm(); + } + await loadProducts(); + } catch (err) { + setStatus(status, err.message, 'error'); + } finally { + $('#saveProduct').disabled = false; + } + }); + + on($('#resetProduct'), 'click', () => resetProductForm()); + on($('#cancelProduct'), 'click', () => resetProductForm()); + + on($('#productsTable'), 'click', async (e) => { + const btn = e.target.closest('button[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + const productId = btn.dataset.id; + if (!productId) return; + + const product = state.products.find((p) => p.id == productId); + if (!product) return; + + if (action === 'editProduct' && product) { + state.editingProductId = product.id; + setProductForm(product); + $('#pName').focus(); + return; + } + + if (action === 'deleteProduct' && product) { + if (!confirm(`Delete product "${product.name}"?`)) return; + try { + await api(`/products/${product.id}`, { method: 'DELETE' }); + await loadProducts(); + if (state.editingProductId === product.id) resetProductForm(); + } catch (err) { + $('#productStatus').style.color = '#dc2626'; + $('#productStatus').textContent = err.message; + } + } + }); + }; + + const initCategories = () => { + const setCategoryForm = ({ id, name, description }) => { + $('#cId').value = id || ''; + $('#cName').value = name || ''; + $('#cDesc').value = description || ''; + $('#categoryFormTitle').textContent = id ? 'Edit Category' : 'Add Category'; + $('#cancelCategory').classList.toggle('hidden', !id); + }; + + const resetCategoryForm = () => { + state.editingCategoryId = null; + $('#cId').value = ''; + $('#cName').value = ''; + $('#cDesc').value = ''; + $('#categoryStatus').textContent = ''; + $('#categoryFormTitle').textContent = 'Add Category'; + $('#cancelCategory').classList.add('hidden'); + }; + + on($('#saveCategory'), 'click', async () => { + const id = $('#cId').value.trim(); + const name = $('#cName').value.trim(); + const description = $('#cDesc').value.trim(); + const status = $('#categoryStatus'); + status.textContent = ''; + + if (!name) { + status.textContent = 'Category name is required.'; + return; + } + + $('#saveCategory').disabled = true; + try { + if (id) { + await api(`/categories/${id}`, { + method: 'PUT', + body: JSON.stringify({ id: parseInt(id, 10), name, description }) + }); + setStatus(status, 'Category updated.', 'success'); + state.editingCategoryId = null; + $('#cancelCategory').classList.add('hidden'); + $('#categoryFormTitle').textContent = 'Add Category'; + } else { + await api('/categories', { + method: 'POST', + body: JSON.stringify({ name, description }) + }); + setStatus(status, 'Category created.', 'success'); + resetCategoryForm(); + } + await loadCategories(); + } catch (err) { + setStatus(status, err.message, 'error'); + } finally { + $('#saveCategory').disabled = false; + } + }); + + on($('#resetCategory'), 'click', () => resetCategoryForm()); + on($('#cancelCategory'), 'click', () => resetCategoryForm()); + + on($('#categoriesTable'), 'click', async (e) => { + const btn = e.target.closest('button[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + const categoryId = btn.dataset.id; + if (!categoryId) return; + + const category = state.categories.find((c) => c.id == categoryId); + if (!category) return; + + if (action === 'editCategory' && category) { + state.editingCategoryId = category.id; + setCategoryForm(category); + $('#cName').focus(); + return; + } + + if (action === 'deleteCategory' && category) { + if (!confirm(`Delete category "${category.name}"? This may affect linked products.`)) return; + try { + await api(`/categories/${category.id}`, { method: 'DELETE' }); + await loadCategories(); + if (state.editingCategoryId === category.id) resetCategoryForm(); + } catch (err) { + $('#categoryStatus').style.color = '#dc2626'; + $('#categoryStatus').textContent = err.message; + } + } + }); + }; + + const initTransactions = () => { + on($('#addTransaction'), 'click', async () => { + const productId = parseInt($('#tProduct').value, 10); + const qty = parseInt($('#tQty').value, 10); + const type = $('#tType').value; + const status = $('#transactionStatus'); + status.textContent = ''; + + if (Number.isNaN(productId) || Number.isNaN(qty) || qty < 1) { + status.textContent = 'Select a product and quantity.'; + return; + } + + const product = state.products.find((p) => p.id === productId); + if (!product) { + status.textContent = 'Selected product not found.'; + return; + } + + $('#addTransaction').disabled = true; + try { + await api('/transactions', { + method: 'POST', + body: JSON.stringify({ productId, productName: product.name, quantity: qty, type, totalAmount: product.price * qty }) + }); + setStatus(status, 'Transaction added.', 'success'); + $('#tQty').value = 1; + await loadTransactions(); + } catch (err) { + setStatus(status, err.message, 'error'); + } finally { + $('#addTransaction').disabled = false; + } + }); + + on($('#refreshTransactions'), 'click', () => loadTransactions()); + }; + + const loadCategories = async () => { + try { + const categories = await api('/categories'); + state.categories = categories; + renderTable('categoriesTable', categories, [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description', value: (c) => c.description || '' } + ], (category) => { + if (!category) return ''; + return ` +
+ + +
+ `; + }); + } catch (err) { + $('#categoriesTable').innerHTML = `
${esc(err.message)}
`; + } + }; + + const loadProducts = async () => { + try { + state.products = await api('/products'); + state.categories = await api('/categories'); + const catSelect = $('#pCategory'); + catSelect.innerHTML = state.categories.map((c) => ``).join(''); + renderTable('productsTable', state.products, [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { key: 'price', label: 'Price', value: (p) => p.price.toFixed(2) }, + { key: 'quantityInStock', label: 'Qty' }, + { key: 'supplier', label: 'Supplier', value: (p) => p.supplier || '' }, + { key: 'categoryId', label: 'Category', value: (p) => (state.categories.find((c) => c.id === p.categoryId) || {}).name || p.categoryId } + ], (product) => { + if (!product) return ''; + return ` +
+ + +
+ `; + }); + } catch (err) { + $('#productsTable').innerHTML = `
${esc(err.message)}
`; + } + }; + + const loadTransactions = async () => { + try { + state.products = await api('/products'); + state.transactions = await api('/transactions'); + const productSelect = $('#tProduct'); + productSelect.innerHTML = state.products.map((p) => ``).join(''); + renderTable('transactionsTable', state.transactions, [ + { key: 'id', label: 'ID' }, + { key: 'productName', label: 'Product' }, + { key: 'type', label: 'Type' }, + { key: 'quantity', label: 'Qty' }, + { key: 'totalAmount', label: 'Amount', value: (t) => Number(t.totalAmount).toFixed(2) }, + { key: 'date', label: 'Date', value: (t) => new Date(t.date).toLocaleString() } + ]); + } catch (err) { + $('#transactionsTable').innerHTML = `
${esc(err.message)}
`; + } + }; + + init(); +})(); diff --git a/IMS.API/wwwroot/js/config.js b/IMS.API/wwwroot/js/config.js new file mode 100644 index 0000000..05d954d --- /dev/null +++ b/IMS.API/wwwroot/js/config.js @@ -0,0 +1,6 @@ +window.IMS_API = { + BASE_URL: './api', + TOKEN_KEY: 'ims_token', + EMAIL_KEY: 'ims_email', + ROLE_KEY: 'ims_role' +}; diff --git a/IMS.Core/Entities/ApiKey.cs b/IMS.Core/Entities/ApiKey.cs new file mode 100644 index 0000000..4b7a44a --- /dev/null +++ b/IMS.Core/Entities/ApiKey.cs @@ -0,0 +1,11 @@ +namespace IMS.Core.Entities; + +public class ApiKey +{ + public int Id { get; set; } + public string Key { get; set; } = string.Empty; + public string? Owner { get; set; } + public DateTime ExpiresAt { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/IMS.Infrastructure/Data/ApplicationDbContext.cs b/IMS.Infrastructure/Data/ApplicationDbContext.cs index 58c8bfd..3e61cd9 100644 --- a/IMS.Infrastructure/Data/ApplicationDbContext.cs +++ b/IMS.Infrastructure/Data/ApplicationDbContext.cs @@ -14,6 +14,7 @@ public ApplicationDbContext(DbContextOptions options) public DbSet Transactions => Set(); public DbSet Payments => Set(); public DbSet LowStockAlerts => Set(); + public DbSet ApiKeys => Set(); protected override void OnModelCreating(ModelBuilder builder) diff --git a/IMS.Infrastructure/Data/DataSeeder.cs b/IMS.Infrastructure/Data/DataSeeder.cs index 9df9ff8..74cc4fc 100644 --- a/IMS.Infrastructure/Data/DataSeeder.cs +++ b/IMS.Infrastructure/Data/DataSeeder.cs @@ -22,7 +22,25 @@ public static async Task SeedDataAsync(IServiceProvider service) if (context.Database.GetPendingMigrationsAsync().GetAwaiter().GetResult().Any()) await context.Database.MigrateAsync(); - if (await context.categories.AnyAsync()) return; + if (await context.categories.AnyAsync()) + { + if (!await context.ApiKeys.AnyAsync()) + { + var defaultApiKey = new ApiKey + { + Key = "IMS-Demo-Key-2026", + Owner = "Demo/Resume", + ExpiresAt = DateTime.UtcNow.AddMonths(1), + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + context.ApiKeys.Add(defaultApiKey); + await context.SaveChangesAsync(); + } + + return; + } var categorypath = Path.Combine(AppContext.BaseDirectory, "Data", "SeedData", "categories.json"); @@ -73,7 +91,20 @@ public static async Task SeedDataAsync(IServiceProvider service) await context.SaveChangesAsync(); - + if (!await context.ApiKeys.AnyAsync()) + { + var defaultApiKey = new ApiKey + { + Key = "IMS-Demo-Key-2026", + Owner = "Demo/Resume", + ExpiresAt = DateTime.UtcNow.AddMonths(1), + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + context.ApiKeys.Add(defaultApiKey); + await context.SaveChangesAsync(); + } } private class ProductSeedDto { diff --git a/IMS.Infrastructure/Migrations/20260812161702_AddApiKey.Designer.cs b/IMS.Infrastructure/Migrations/20260812161702_AddApiKey.Designer.cs new file mode 100644 index 0000000..7ff8da8 --- /dev/null +++ b/IMS.Infrastructure/Migrations/20260812161702_AddApiKey.Designer.cs @@ -0,0 +1,533 @@ +ο»Ώ// +using System; +using IMS.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IMS.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260812161702_AddApiKey")] + partial class AddApiKey + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.30") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("IMS.Core.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Key") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("IMS.Core.Entities.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("IMS.Core.Entities.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("categories"); + }); + + modelBuilder.Entity("IMS.Core.Entities.LowStockAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertDate") + .HasColumnType("datetime2"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("ResolvedDate") + .HasColumnType("datetime2"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("LowStockAlerts"); + }); + + modelBuilder.Entity("IMS.Core.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PaymentDate") + .HasColumnType("datetime2"); + + b.Property("PaymentMethod") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionId") + .HasColumnType("int"); + + b.Property("TransactionReference") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("IMS.Core.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("QuantityInStock") + .HasColumnType("int"); + + b.Property("Supplier") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("IMS.Core.Entities.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("UserId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("IMS.Core.Entities.LowStockAlert", b => + { + b.HasOne("IMS.Core.Entities.Product", "Product") + .WithMany("Alerts") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("IMS.Core.Entities.Payment", b => + { + b.HasOne("IMS.Core.Entities.Transaction", null) + .WithMany() + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("IMS.Core.Entities.Product", b => + { + b.HasOne("IMS.Core.Entities.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("IMS.Core.Entities.Transaction", b => + { + b.HasOne("IMS.Core.Entities.Product", "Product") + .WithMany("Transactions") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("IMS.Core.Entities.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Product"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("IMS.Core.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("IMS.Core.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IMS.Core.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("IMS.Core.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("IMS.Core.Entities.Category", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("IMS.Core.Entities.Product", b => + { + b.Navigation("Alerts"); + + b.Navigation("Transactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/IMS.Infrastructure/Migrations/20260812161702_AddApiKey.cs b/IMS.Infrastructure/Migrations/20260812161702_AddApiKey.cs new file mode 100644 index 0000000..d6965d8 --- /dev/null +++ b/IMS.Infrastructure/Migrations/20260812161702_AddApiKey.cs @@ -0,0 +1,39 @@ +ο»Ώusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IMS.Infrastructure.Migrations +{ + /// + public partial class AddApiKey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApiKeys", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Key = table.Column(type: "nvarchar(max)", nullable: false), + Owner = table.Column(type: "nvarchar(max)", nullable: true), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + IsActive = table.Column(type: "bit", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiKeys", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiKeys"); + } + } +} diff --git a/IMS.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/IMS.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 28a730d..c15f7ea 100644 --- a/IMS.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/IMS.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -17,11 +17,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.27") + .HasAnnotation("ProductVersion", "8.0.30") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("IMS.Core.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Key") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ApiKeys"); + }); + modelBuilder.Entity("IMS.Core.Entities.ApplicationUser", b => { b.Property("Id") diff --git a/IMS.WebClient/css/styles.css b/IMS.WebClient/css/styles.css new file mode 100644 index 0000000..992d0cb --- /dev/null +++ b/IMS.WebClient/css/styles.css @@ -0,0 +1,240 @@ +:root { + --bg: #f3f4f6; + --surface: #ffffff; + --text: #111827; + --muted: #6b7280; + --border: #e5e7eb; + --primary: #2563eb; + --primary-600: #1d4ed8; + --success: #16a34a; + --danger: #dc2626; + --warning: #f59e0b; + --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 6px 18px rgba(0,0,0,0.06); + --radius: 14px; +} + +* { box-sizing: border-box; } + +html, body { + height: 100%; +} + +body { + font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, sans-serif; + margin: 0; + padding: 18px; + background: var(--bg); + color: var(--text); + line-height: 1.45; +} + +a { + color: inherit; + text-decoration: none; +} + +.hidden { + display: none !important; +} + +.header { + background: #0f172a; + color: #fff; + padding: 16px 22px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + border-bottom: 1px solid rgba(255,255,255,0.08); +} + +.header .brand { + font-weight: 800; + font-size: 18px; + letter-spacing: 0.2px; +} + +.header .user { + font-size: 13px; + opacity: 0.85; +} + +.container { + max-width: 1140px; + margin: 24px auto; + padding: 0 18px; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 16px; + box-shadow: var(--shadow); +} + +.title { + font-size: 22px; + font-weight: 800; + margin: 0 0 14px; + color: #0f172a; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 24px; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: none; + padding: 10px 14px; + border-radius: 12px; + cursor: pointer; + font-weight: 700; + font-size: 14px; + color: #fff; + background: var(--primary); + transition: transform 0.08s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.btn:hover { background: var(--primary-600); } +.btn:active { transform: translateY(1px); } + +.btn.secondary { background: #6b7280; } +.btn.secondary:hover { background: #4b5563; } + +.btn.success { background: var(--success); } +.btn.success:hover { background: #15803d; } + +.btn.danger { background: var(--danger); } +.btn.danger:hover { background: #b91c1c; } + +.btn.ghost { + background: #eef2ff; + color: #1e3a8a; +} +.btn.ghost:hover { background: #e0e7ff; } + +input, select, textarea { + width: 100%; + padding: 10px 11px; + border-radius: 12px; + border: 1px solid var(--border); + background: #fff; + font: inherit; + color: var(--text); + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +input:focus, select:focus, textarea:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} + +label { + display: block; + font-size: 13px; + font-weight: 700; + color: #374151; + margin: 10px 10px 6px; +} + +.form-grid > div { + margin-bottom: 14px; + padding: 0 6px; +} + +.table { + width: 100%; + border-collapse: collapse; + margin-top: 12px; + background: var(--surface); + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--border); +} + +.table th, .table td { + padding: 12px 13px; + text-align: left; + border-bottom: 1px solid #f3f4f6; + font-size: 14px; +} + +.table th { + background: #f9fafb; + font-weight: 700; + color: #374151; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.table tbody tr:hover { + background: #fafafa; +} + +.login { + max-width: 420px; + margin: 80px auto; + padding: 24px; +} + +.status { + margin-top: 10px; + font-size: 14px; + color: var(--danger); +} + +.status.success { + color: var(--success); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.actions-right { + margin-left: auto; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + background: #eef2ff; + color: #1e3a8a; +} + +.empty { + text-align: center; + color: var(--muted); + padding: 28px 10px; + font-size: 14px; +} + +@media (max-width: 640px) { + .header { + padding: 14px 16px; + } + .container { + margin: 16px auto; + padding: 0 14px; + } + .title { + font-size: 20px; + } +} diff --git a/IMS.WebClient/index.html b/IMS.WebClient/index.html new file mode 100644 index 0000000..a916c77 --- /dev/null +++ b/IMS.WebClient/index.html @@ -0,0 +1,14 @@ + + + + + + IMS Web + + + +
+ + + + diff --git a/IMS.WebClient/js/app.js b/IMS.WebClient/js/app.js new file mode 100644 index 0000000..0c9420c --- /dev/null +++ b/IMS.WebClient/js/app.js @@ -0,0 +1,710 @@ +(() => { + const $ = (sel) => document.querySelector(sel); + const on = (el, ev, fn) => el.addEventListener(ev, fn); + const canManage = () => state.role === 'Admin' || state.role === 'Manager'; + + const state = { + email: localStorage.getItem(IMS_API.EMAIL_KEY) || '', + token: localStorage.getItem(IMS_API.TOKEN_KEY) || '', + role: localStorage.getItem(IMS_API.ROLE_KEY) || '', + products: [], + categories: [], + transactions: [], + editingCategoryId: null, + editingProductId: null + }; + + const authHeaders = () => ({ + 'Content-Type': 'application/json', + ...(state.token ? { Authorization: `Bearer ${state.token}` } : {}) + }); + + const api = async (path, opts = {}) => { + const res = await fetch(IMS_API.BASE_URL + path, { + ...opts, + headers: { + ...authHeaders(), + ...(opts.headers || {}) + } + }); + + if (res.status === 401) { + logout(); + throw new Error('Unauthorized'); + } + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `HTTP ${res.status}`); + } + + if (res.status === 204) return null; + + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('application/json')) return res.json(); + return res.text(); + }; + + const setAuth = (data) => { + state.token = data.token; + state.email = data.email; + state.role = (data.roles || [])[0] || ''; + localStorage.setItem(IMS_API.TOKEN_KEY, data.token); + localStorage.setItem(IMS_API.EMAIL_KEY, data.email); + localStorage.setItem(IMS_API.ROLE_KEY, state.role); + }; + + const logout = () => { + state.token = ''; + state.email = ''; + state.role = ''; + localStorage.removeItem(IMS_API.TOKEN_KEY); + localStorage.removeItem(IMS_API.EMAIL_KEY); + localStorage.removeItem(IMS_API.ROLE_KEY); + }; + + const show = (id) => { + document.querySelectorAll('#app > section').forEach((el) => el.classList.add('hidden')); + const target = document.getElementById(id); + if (target) target.classList.remove('hidden'); + }; + + const esc = (val) => { + if (val == null) return ''; + return String(val) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + }; + + const renderTable = (containerId, rows, columns, actions) => { + const container = document.getElementById(containerId); + if (!container) return; + if (!rows.length) { + container.innerHTML = '
No records yet.
'; + return; + } + + let html = ` +
+ + + + ${columns.map((col) => ``).join('')} + ${actions && canManage() ? '' : ''} + + + + ${rows.map((row) => ` + + ${columns.map((col) => ``).join('')} + ${actions && canManage() ? `` : ''} + + `).join('')} + +
${esc(col.label)}Actions
${esc(col.value ? col.value(row) : row[col.key])}${actions(row)}
+
+ `; + container.innerHTML = html; + }; + + const confirm = (message) => window.confirm(message || 'Are you sure?'); + + const setStatus = (el, message, type = 'error') => { + if (!el) return; + el.textContent = message || ''; + el.className = 'status ' + (type === 'success' ? 'success' : ''); + }; + + const loginPage = () => ` + + `; + + const dashboardPage = () => { + const canManage = state.role === 'Admin' || state.role === 'Manager'; + return ` + + `; + }; + + const productsPage = () => ` + + `; + + const categoriesPage = () => ` + + `; + + const transactionsPage = () => ` + + `; + + const init = () => { + const app = $('#app'); + app.innerHTML = state.token + ? dashboardPage() + categoriesPage() + productsPage() + transactionsPage() + : loginPage(); + + if (state.token) { + initDashboard(); + initProducts(); + initCategories(); + initTransactions(); + show('dashboard'); + } else { + initLogin(); + show('login'); + } + }; + + const initLogin = () => { + on($('#loginBtn'), 'click', async () => { + const email = $('#loginEmail').value.trim(); + const password = $('#loginPassword').value.trim(); + const errorEl = $('#loginError'); + errorEl.textContent = ''; + + if (!email || !password) { + errorEl.textContent = 'Enter email and password.'; + return; + } + + $('#loginBtn').disabled = true; + $('#loginBtn').textContent = 'Logging in...'; + + try { + const data = await api('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }) + }); + + setAuth(data); + init(); + } catch (err) { + errorEl.textContent = err.message || 'Login failed.'; + } finally { + $('#loginBtn').disabled = false; + $('#loginBtn').textContent = 'Login'; + } + }); + }; + + const initDashboard = () => { + on($('#logoutBtn'), 'click', () => { + logout(); + init(); + }); + + document.querySelectorAll('.open-page').forEach((btn) => { + on(btn, 'click', async () => { + const page = btn.dataset.page; + show(page); + if (page === 'products') await loadProducts(); + if (page === 'categories') await loadCategories(); + if (page === 'transactions') await loadTransactions(); + }); + }); + + const doImport = async (file) => { + const form = new FormData(); + form.append('file', file); + const res = await fetch(IMS_API.BASE_URL + '/products/import', { + method: 'POST', + headers: authHeaders(), + body: form + }); + if (!res.ok) throw new Error('Import failed'); + alert('Import completed.'); + }; + + on($('#exportCsv'), 'click', async () => { + const res = await fetch(IMS_API.BASE_URL + '/products/export', { + headers: authHeaders(), + }); + if (!res.ok) throw new Error('Export failed'); + const csv = await res.text(); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'products_export.csv'; + a.click(); + URL.revokeObjectURL(url); + }); + + on($('#importCsv'), 'change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + try { + $('#importCsv').disabled = true; + await doImport(file); + await loadProducts(); + } catch (err) { + alert(err.message); + } finally { + $('#importCsv').disabled = false; + $('#importCsv').value = ''; + } + }); + }; + + const initProducts = () => { + const setProductForm = ({ id, name, price, quantityInStock, supplier, categoryId, description }) => { + $('#pId').value = id || ''; + $('#pName').value = name || ''; + $('#pPrice').value = price ?? ''; + $('#pQty').value = quantityInStock ?? ''; + $('#pSupplier').value = supplier || ''; + $('#pDesc').value = description || ''; + $('#pCategory').value = categoryId || ''; + $('#productFormTitle').textContent = id ? 'Edit Product' : 'Add Product'; + $('#cancelProduct').classList.toggle('hidden', !id); + }; + + const resetProductForm = () => { + state.editingProductId = null; + $('#pId').value = ''; + $('#pName').value = ''; + $('#pPrice').value = ''; + $('#pQty').value = ''; + $('#pSupplier').value = ''; + $('#pDesc').value = ''; + $('#pCategory').value = ''; + $('#productStatus').textContent = ''; + $('#productFormTitle').textContent = 'Add Product'; + $('#cancelProduct').classList.add('hidden'); + }; + + on($('#saveProduct'), 'click', async () => { + const id = $('#pId').value.trim(); + const name = $('#pName').value.trim(); + const price = parseFloat($('#pPrice').value); + const qty = parseInt($('#pQty').value, 10); + const supplier = $('#pSupplier').value.trim(); + const categoryId = parseInt($('#pCategory').value, 10); + const desc = $('#pDesc').value.trim(); + const status = $('#productStatus'); + status.textContent = ''; + + if (!name || Number.isNaN(price) || Number.isNaN(qty) || Number.isNaN(categoryId)) { + status.textContent = 'Fill required product fields.'; + return; + } + + $('#saveProduct').disabled = true; + try { + if (id) { + await api(`/products/${id}`, { + method: 'PUT', + body: JSON.stringify({ id: parseInt(id, 10), name, price, quantityInStock: qty, supplier, categoryId, description: desc }) + }); + setStatus(status, 'Product updated.', 'success'); + state.editingProductId = null; + $('#cancelProduct').classList.add('hidden'); + $('#productFormTitle').textContent = 'Add Product'; + } else { + await api('/products', { + method: 'POST', + body: JSON.stringify({ name, price, quantityInStock: qty, supplier, categoryId, description: desc }) + }); + setStatus(status, 'Product created.', 'success'); + resetProductForm(); + } + await loadProducts(); + } catch (err) { + setStatus(status, err.message, 'error'); + } finally { + $('#saveProduct').disabled = false; + } + }); + + on($('#resetProduct'), 'click', () => resetProductForm()); + on($('#cancelProduct'), 'click', () => resetProductForm()); + + on($('#productsTable'), 'click', async (e) => { + const btn = e.target.closest('button[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + const productId = btn.dataset.id; + if (!productId) return; + + const product = state.products.find((p) => p.id == productId); + if (!product) return; + + if (action === 'editProduct' && product) { + state.editingProductId = product.id; + setProductForm(product); + $('#pName').focus(); + return; + } + + if (action === 'deleteProduct' && product) { + if (!confirm(`Delete product "${product.name}"?`)) return; + try { + await api(`/products/${product.id}`, { method: 'DELETE' }); + await loadProducts(); + if (state.editingProductId === product.id) resetProductForm(); + } catch (err) { + $('#productStatus').style.color = '#dc2626'; + $('#productStatus').textContent = err.message; + } + } + }); + }; + + const initCategories = () => { + const setCategoryForm = ({ id, name, description }) => { + $('#cId').value = id || ''; + $('#cName').value = name || ''; + $('#cDesc').value = description || ''; + $('#categoryFormTitle').textContent = id ? 'Edit Category' : 'Add Category'; + $('#cancelCategory').classList.toggle('hidden', !id); + }; + + const resetCategoryForm = () => { + state.editingCategoryId = null; + $('#cId').value = ''; + $('#cName').value = ''; + $('#cDesc').value = ''; + $('#categoryStatus').textContent = ''; + $('#categoryFormTitle').textContent = 'Add Category'; + $('#cancelCategory').classList.add('hidden'); + }; + + on($('#saveCategory'), 'click', async () => { + const id = $('#cId').value.trim(); + const name = $('#cName').value.trim(); + const description = $('#cDesc').value.trim(); + const status = $('#categoryStatus'); + status.textContent = ''; + + if (!name) { + status.textContent = 'Category name is required.'; + return; + } + + $('#saveCategory').disabled = true; + try { + if (id) { + await api(`/categories/${id}`, { + method: 'PUT', + body: JSON.stringify({ id: parseInt(id, 10), name, description }) + }); + setStatus(status, 'Category updated.', 'success'); + state.editingCategoryId = null; + $('#cancelCategory').classList.add('hidden'); + $('#categoryFormTitle').textContent = 'Add Category'; + } else { + await api('/categories', { + method: 'POST', + body: JSON.stringify({ name, description }) + }); + setStatus(status, 'Category created.', 'success'); + resetCategoryForm(); + } + await loadCategories(); + } catch (err) { + setStatus(status, err.message, 'error'); + } finally { + $('#saveCategory').disabled = false; + } + }); + + on($('#resetCategory'), 'click', () => resetCategoryForm()); + on($('#cancelCategory'), 'click', () => resetCategoryForm()); + + on($('#categoriesTable'), 'click', async (e) => { + const btn = e.target.closest('button[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + const categoryId = btn.dataset.id; + if (!categoryId) return; + + const category = state.categories.find((c) => c.id == categoryId); + if (!category) return; + + if (action === 'editCategory' && category) { + state.editingCategoryId = category.id; + setCategoryForm(category); + $('#cName').focus(); + return; + } + + if (action === 'deleteCategory' && category) { + if (!confirm(`Delete category "${category.name}"? This may affect linked products.`)) return; + try { + await api(`/categories/${category.id}`, { method: 'DELETE' }); + await loadCategories(); + if (state.editingCategoryId === category.id) resetCategoryForm(); + } catch (err) { + $('#categoryStatus').style.color = '#dc2626'; + $('#categoryStatus').textContent = err.message; + } + } + }); + }; + + const initTransactions = () => { + on($('#addTransaction'), 'click', async () => { + const productId = parseInt($('#tProduct').value, 10); + const qty = parseInt($('#tQty').value, 10); + const type = $('#tType').value; + const status = $('#transactionStatus'); + status.textContent = ''; + + if (Number.isNaN(productId) || Number.isNaN(qty) || qty < 1) { + status.textContent = 'Select a product and quantity.'; + return; + } + + const product = state.products.find((p) => p.id === productId); + if (!product) { + status.textContent = 'Selected product not found.'; + return; + } + + $('#addTransaction').disabled = true; + try { + await api('/transactions', { + method: 'POST', + body: JSON.stringify({ productId, productName: product.name, quantity: qty, type, totalAmount: product.price * qty }) + }); + setStatus(status, 'Transaction added.', 'success'); + $('#tQty').value = 1; + await loadTransactions(); + } catch (err) { + setStatus(status, err.message, 'error'); + } finally { + $('#addTransaction').disabled = false; + } + }); + + on($('#refreshTransactions'), 'click', () => loadTransactions()); + }; + + const loadCategories = async () => { + try { + const categories = await api('/categories'); + state.categories = categories; + renderTable('categoriesTable', categories, [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description', value: (c) => c.description || '' } + ], (category) => { + if (!category) return ''; + return ` +
+ + +
+ `; + }); + } catch (err) { + $('#categoriesTable').innerHTML = `
${esc(err.message)}
`; + } + }; + + const loadProducts = async () => { + try { + state.products = await api('/products'); + state.categories = await api('/categories'); + const catSelect = $('#pCategory'); + catSelect.innerHTML = state.categories.map((c) => ``).join(''); + renderTable('productsTable', state.products, [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { key: 'price', label: 'Price', value: (p) => p.price.toFixed(2) }, + { key: 'quantityInStock', label: 'Qty' }, + { key: 'supplier', label: 'Supplier', value: (p) => p.supplier || '' }, + { key: 'categoryId', label: 'Category', value: (p) => (state.categories.find((c) => c.id === p.categoryId) || {}).name || p.categoryId } + ], (product) => { + if (!product) return ''; + return ` +
+ + +
+ `; + }); + } catch (err) { + $('#productsTable').innerHTML = `
${esc(err.message)}
`; + } + }; + + const loadTransactions = async () => { + try { + state.products = await api('/products'); + state.transactions = await api('/transactions'); + const productSelect = $('#tProduct'); + productSelect.innerHTML = state.products.map((p) => ``).join(''); + renderTable('transactionsTable', state.transactions, [ + { key: 'id', label: 'ID' }, + { key: 'productName', label: 'Product' }, + { key: 'type', label: 'Type' }, + { key: 'quantity', label: 'Qty' }, + { key: 'totalAmount', label: 'Amount', value: (t) => Number(t.totalAmount).toFixed(2) }, + { key: 'date', label: 'Date', value: (t) => new Date(t.date).toLocaleString() } + ]); + } catch (err) { + $('#transactionsTable').innerHTML = `
${esc(err.message)}
`; + } + }; + + init(); +})(); diff --git a/IMS.WebClient/js/config.js b/IMS.WebClient/js/config.js new file mode 100644 index 0000000..25a90c2 --- /dev/null +++ b/IMS.WebClient/js/config.js @@ -0,0 +1,11 @@ +window.IMS_API = { + BASE_URL: (() => { + const fromMeta = document.querySelector('meta[name="ims-api-base"]')?.getAttribute('content'); + const fromQuery = new URLSearchParams(window.location.search).get('api'); + const fromEnv = window.__IMS_API_BASE__; + return fromMeta || fromQuery || fromEnv || '/api'; + })(), + TOKEN_KEY: 'ims_token', + EMAIL_KEY: 'ims_email', + ROLE_KEY: 'ims_role' +}; diff --git a/README.md b/README.md index 6d10f4d..ea5394e 100644 --- a/README.md +++ b/README.md @@ -1 +1,210 @@ -# IMS-Backend \ No newline at end of file +# IMS β€” Inventory Management System + +A full-featured **ASP.NET Core 8 Web API** for inventory management, built with **Clean Architecture**, **JWT authentication**, **role-based authorization**, **CSV import/export**, and **automated email notifications** for low stock alerts. + +--- + +## Architecture + +The solution follows **Clean Architecture** principles with three layers: + +``` +IMS.Core β†’ Domain entities, interfaces, business logic (no dependencies) +IMS.Infrastructure β†’ EF Core, Identity, external services (email, data access) +IMS.API β†’ Controllers, middleware, DI registration, configuration +``` + +This keeps the domain layer independent of frameworks and infrastructure concerns. + +--- + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Framework | ASP.NET Core 8 (.NET 8) | +| ORM | Entity Framework Core 8 | +| Database | SQL Server (LocalDB) | +| Auth | ASP.NET Identity + JWT Bearer | +| Role System | Admin, Manager, Staff | +| Email | SMTP via System.Net.Mail (Gmail) | +| CSV | CsvHelper | +| Documentation | Swagger / OpenAPI | +| API Client | Bruno (API testing) | + +--- + +## Features + +### Authentication & Authorization (JWT) +- User registration and login +- JWT token generation with role claims +- Three roles: **Admin**, **Manager**, **Staff** +- Role-based access control on every endpoint + +### Products Management +- Full CRUD operations +- Export all products to CSV file +- Import products from CSV (bulk add/update) +- Category association + +### Categories Management +- Create, read, update, delete categories +- Products linked via foreign key + +### Transactions +- Record sales (decreases stock) and purchases (increases stock) +- Insufficient stock validation on sales +- Auto-triggers **low stock email alert** when quantity drops below threshold + +### Payments +- Payment tracking with status (Pending/Completed/Failed) +- Payment method and transaction reference +- Full CRUD with role-based access + +### Low Stock Alerts +- Configurable threshold (default: 10) +- Automatic email notification via SMTP when stock drops below threshold +- Email includes product name, current quantity, and threshold +- Graceful error handling β€” email failure does not break the transaction + +### Reports +- Inventory summary: total stock value, product count +- Sales and purchases breakdown with totals +- Top selling products ranking + +### Data Seeding +- Role seeder: creates Admin, Manager, Staff roles + test users +- Data seeder: reads JSON files to seed categories and products +- Idempotent β€” only runs once, safe to restart + +--- + +## API Endpoints + +### Auth +| Method | Endpoint | Access | Description | +|--------|----------|--------|-------------| +| POST | /api/auth/register | Public | Register new user | +| POST | /api/auth/login | Public | Login, returns JWT token | + +### Products +| Method | Endpoint | Access | Description | +|--------|----------|--------|-------------| +| GET | /api/products | Admin, Manager, Staff | Get all products | +| GET | /api/products/{id} | Admin, Manager, Staff | Get product by ID | +| GET | /api/products/export | Admin, Manager | Export products to CSV | +| POST | /api/products/import | Admin, Manager | Import products from CSV | +| POST | /api/products | Admin, Manager | Create product | +| PUT | /api/products/{id} | Admin, Manager | Update product | +| DELETE | /api/products/{id} | Admin | Delete product | + +### Categories +| Method | Endpoint | Access | Description | +|--------|----------|--------|-------------| +| GET | /api/categories | Admin, Manager, Staff | Get all categories | +| GET | /api/categories/{id} | Admin, Manager, Staff | Get category by ID | +| POST | /api/categories | Admin, Manager | Create category | +| PUT | /api/categories/{id} | Admin | Update category | +| DELETE | /api/categories/{id} | Admin | Delete category | + +### Transactions +| Method | Endpoint | Access | Description | +|--------|----------|--------|-------------| +| GET | /api/transactions | Admin, Manager | Get all transactions | +| GET | /api/transactions/{id} | Admin, Manager | Get transaction by ID | +| POST | /api/transactions | Admin, Manager, Staff | Record sale or purchase | + +### Payments +| Method | Endpoint | Access | Description | +|--------|----------|--------|-------------| +| GET | /api/payments | Admin, Manager | Get all payments | +| GET | /api/payments/{id} | Admin, Manager | Get payment by ID | +| POST | /api/payments | Admin, Manager | Create payment | +| PUT | /api/payments/{id} | Admin, Manager | Update payment | +| DELETE | /api/payments/{id} | Admin | Delete payment | + +### Reports +| Method | Endpoint | Access | Description | +|--------|----------|--------|-------------| +| GET | /api/reports | Admin, Manager | Get inventory report | + +### Low Stock Alerts +| Method | Endpoint | Access | Description | +|--------|----------|--------|-------------| +| POST | /api/lowStockAlerts | Admin, Manager | Create low stock alert | +| GET | /api/lowStockAlerts | Admin, Manager | Get all low stock alerts | + +--- + +## Role Permissions + +| Feature | Admin | Manager | Staff | +|---------|-------|---------|-------| +| View Products/Categories | βœ… | βœ… | βœ… | +| Create/Edit Products | βœ… | βœ… | ❌ | +| Delete Products | βœ… | ❌ | ❌ | +| Export/Import CSV | βœ… | βœ… | ❌ | +| Record Sales/Purchases | βœ… | βœ… | βœ… | +| View Transactions | βœ… | βœ… | ❌ | +| Manage Payments | βœ… | βœ… | ❌ | +| View Reports | βœ… | βœ… | ❌ | +| Manage Alerts | βœ… | βœ… | ❌ | + +--- + +## Getting Started + +### Prerequisites +- .NET 8 SDK +- SQL Server (LocalDB or full instance) +- Gmail App Password (for email notifications) + +### Setup + +1. **Clone the repository** + ```bash + git clone https://github.com/Mariomedhat899/IMS-Backend.git + cd IMS-Backend + ``` + +2. **Configure the database connection** in `IMS.API/appsettings.json`: + ```json + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=IMS_DB;Trusted_Connection=True;TrustServerCertificate=True" + } + ``` + +3. **Set up User Secrets** (for SMTP credentials): + ```bash + cd IMS.API + dotnet user-secrets init + dotnet user-secrets set "SmtpSettings:Password" "your-gmail-app-password" + ``` + +4. **Build and run** + ```bash + dotnet build + cd IMS.API + dotnet run + ``` + +5. **Access Swagger UI** at `https://localhost:/swagger` + +6. **Seed data** is applied automatically on startup: + - 3 roles: Admin, Manager, Staff + - 1 admin user, 1 manager user, 1 staff user + - 5 categories + - 10 products + +--- + +## Design Patterns & Principles + +- **Clean Architecture** β€” Core layer has zero external dependencies +- **Repository Pattern** β€” EF Core DbContext as unit of work +- **Dependency Injection** β€” All services registered in DI container +- **Interface Segregation** β€” `IEmailService` abstraction +- **Idempotent Seeders** β€” Safe to run on every startup +- **Graceful Degradation** β€” Email failures don't break transactions +- **Role-Based Access Control** β€” Declarative authorization attributes diff --git a/fix_auth.py b/fix_auth.py new file mode 100644 index 0000000..7ec51ce --- /dev/null +++ b/fix_auth.py @@ -0,0 +1,20 @@ +import os +paths = [ + r'C:\Users\mario\source\repos\IMS-Backend\IMS.API\wwwroot\js\app.js', + r'C:\Users\mario\source\repos\IMS-Backend\IMS.WebClient\js\app.js' +] +old = '*** ${state.token}' +new = '`Bearer ${state.token}' +for path in paths: + if not os.path.exists(path): + print(f'missing: {path}') + continue + with open(path, 'r', encoding='utf-8') as f: + s = f.read() + if old in s: + s = s.replace(old, new) + with open(path, 'w', encoding='utf-8') as f: + f.write(s) + print(f'fixed: {path}') + else: + print(f'noop: {path}') diff --git a/products.csv b/products.csv new file mode 100644 index 0000000..364fea1 --- /dev/null +++ b/products.csv @@ -0,0 +1,21 @@ +Id,Name,Description,Price,QuantityInStock,Supplier,CategoryId +1,Wireless Mouse,Ergonomic wireless mouse with 2.4GHz connectivity,29.99,150,TechGear Inc,1 +2,USB-C Hub,7-in-1 USB-C hub with HDMI and card reader,49.99,85,ConnectAll Ltd,1 +3,Office Chair,Adjustable lumbar support mesh office chair,199.99,30,ComfortSeating Co,2 +4,Standing Desk,Electric height-adjustable standing desk 140x70cm,449.99,12,WorkSpace Pro,2 +5,LED Desk Lamp,Dimmable LED desk lamp with USB charging port,34.99,200,BrightLight GmbH,3 +6,Monitor 27" IPS,27-inch 4K IPS monitor with HDR support,379.99,45,DisplayMax Corp,1 +7,Keyboard Mechanical,Mechanical keyboard RGB backlit blue switches,79.99,120,TechGear Inc,1 +8,Webcam HD 1080p,Full HD webcam with built-in microphone,59.99,95,ViewClear Tech,1 +9,Packaging Boxes Large,Cardboard shipping boxes 40x30x30cm pack of 50,24.99,500,PackRight Supply,4 +10,Stretch Wrap Film,Pallet stretch wrap film 500mm x 300m,18.50,300,PackRight Supply,4 +11,Shipping Labels,Thermal shipping labels 100x150mm roll of 1000,15.99,400,LabelPro Systems,4 +12,Packing Tape Clear,Acrylic packing tape 48mm x 100m 6-pack,12.99,600,PackRight Supply,4 +13,Printer Paper A4,A4 white copy paper 80gsm ream of 500 sheets,6.99,1000,OfficeBasics Ltd,5 +14,Ballpoint Pens Blue,Ballpoint pens blue ink pack of 50,9.99,800,OfficeBasics Ltd,5 +15,Sticky Notes 3x3,Sticky notes 3x3 inches yellow pad of 100 sheets,3.49,1200,OfficeBasics Ltd,5 +16,Whiteboard 90x60,Magnetic dry-erase whiteboard with markers,45.99,40,OfficeBasics Ltd,5 +17,Air Purifier,HEPA air purifier for rooms up to 40m2,159.99,25,CleanAir Solutions,3 +18,Surge Protector,8-outlet surge protector with USB ports 2m cable,22.99,180,PowerSafe Electric,3 +19,Network Cable Cat6,Ethernet cable Cat6 5m blue,8.99,500,NetLink Cables,1 +20,Label Printer,Thermal label printer for shipping and barcode labels,89.99,35,LabelPro Systems,4 diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..234b0a8 --- /dev/null +++ b/railway.toml @@ -0,0 +1,7 @@ +[build] +builder = "nixpacks" + +[deploy] +startCommand = "dotnet IMS.API/bin/Release/net8.0/publish/IMS.API.dll" +restartPolicyType = "on_failure" +restartPolicyMaxRetries = 10 diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..b2b6fca --- /dev/null +++ b/start.sh @@ -0,0 +1,3 @@ +#!/bin/bash +dotnet publish IMS.API/IMS.API.csproj -c Release -o publish +dotnet publish/IMS.API.dll