chore: remove legacy HTML inventory-dash prototype (superseded by React port)
This commit is contained in:
@@ -1,54 +0,0 @@
|
||||
# سامانه مدیریت انبار — فرانتاند متصل به استک (FastAPI + PostgreSQL + Casdoor)
|
||||
|
||||
معماری نهایی:
|
||||
|
||||
```
|
||||
مرورگر (فرانت HTML — inventory-system.html / change-inventory.html)
|
||||
│
|
||||
├── ورود: Casdoor (OIDC + PKCE) یا رمز آزمایشی محلی
|
||||
│ → نقش/پروژه از پروفایل محلی (ایمیل ⇄ مدیر پروژه)
|
||||
│
|
||||
└── داده: هدرهای X-User-Role / X-Project-Id / X-User-Name-B64
|
||||
→ FastAPI (/api/v1) → PostgreSQL
|
||||
```
|
||||
|
||||
## اجرا
|
||||
|
||||
**استک (Docker):**
|
||||
```
|
||||
cd v1.2
|
||||
docker compose up -d --build
|
||||
```
|
||||
- فرانت: http://localhost:3000 (nginx — پراکسی /api به بکاند)
|
||||
- API: http://localhost:8000/docs
|
||||
- Casdoor: http://localhost:8080 (admin/123) — بعد از seed: کاربرها mousavi/goli/mohaghegh/razaei با رمز 123456
|
||||
- seed استک: `py seed_stack.py` (واحد/پروژه/برند/متریال/انبار/تراکنش نمونه)
|
||||
- seed Casdoor: `cd casdoor && py seed.py` (سازمان/اپلیکیشن OIDC/کاربران + تولید casdoor-config.js)
|
||||
|
||||
**اجرای محلی بدون Docker برای فرانت:**
|
||||
```
|
||||
py -m http.server 8123
|
||||
```
|
||||
و `app-config.js` را با `apiBase: 'http://localhost:8000/api/v1'` بگذارید.
|
||||
|
||||
## ساختار
|
||||
|
||||
| فایل | نقش |
|
||||
|---|---|
|
||||
| `inventory-system.html` | داشبورد، کنترل روزانه، انبارها، سازماندهی، ورود |
|
||||
| `change-inventory.html` | صفحه تغییر موجودی (فایل مستقل — فقط «برگشت به داشبوردها») |
|
||||
| `core.js` | هویت/نشست + لایه API (fetch + هدرهای نقش) + آینه دادهها |
|
||||
| `style.css` | استایل مشترک |
|
||||
| `casdoor-config.js` | تنظیمات اتصال به Casdoor (تولیدشده توسط seed) |
|
||||
| `app-config.js` | آدرس API بکاند |
|
||||
| `callback.html` | بازگشت OIDC (تبدیل کد به توکن و نشست) |
|
||||
| `test-logic-v3.mjs` | تست (هویت + لایه API در برابر استک واقعی) → ALL_LOGIC_V3_OK |
|
||||
| `v1.2/` | استک: backend (FastAPI) + frontend (nginx) + sql + seed_stack.py + docker-compose.yml |
|
||||
| `casdoor/` | docker-compose Casdoor + PostgreSQL + seed.py |
|
||||
|
||||
## مدل دسترسی
|
||||
|
||||
- **ادمین**: فقطخواندنی برای موجودی؛ مدیریت ساختار (پروژه/انبار/مدیران) در «سازماندهی».
|
||||
- **مدیر پروژه**: ثبت و مشاهده فقط در انبارهای پروژه خودش (اعمال در بکاند با هدر X-Project-Id).
|
||||
- کاربران محلی (ایمیل ⇄ نقش/پروژه) در `localStorage` نگهداری میشوند؛ هویت واقعی از Casdoor.
|
||||
- رمزهای آزمایشی محلی: 10 ادمین، 1/2 مدیر پروژه برج آیتا، 3 مدیر پروژه الماس.
|
||||
@@ -1,6 +0,0 @@
|
||||
// پیکربندی برنامه — آدرس API بکاند
|
||||
// - در استک Docker (nginx): '/api/v1' (پراکسی به کانتینر api)
|
||||
// - در اجرای محلی با http.server: آدرس کامل http://localhost:8000/api/v1
|
||||
window.APP_CONFIG = {
|
||||
apiBase: 'http://localhost:8000/api/v1'
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>ورود — سامانه مدیریت انبار</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=3">
|
||||
</head>
|
||||
<body>
|
||||
<div id="login">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/></svg>
|
||||
</div>
|
||||
<h1>سامانه مدیریت انبار</h1>
|
||||
<div class="sub">در حال بازگشت از حساب سازمانی…</div>
|
||||
<div id="msg" style="margin-top:14px; font-size:13px; font-weight:600"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="casdoor-config.js?v=3"></script>
|
||||
<script>
|
||||
(function(){
|
||||
function show(msg, isErr){
|
||||
var el=document.getElementById('msg');
|
||||
el.textContent=msg;
|
||||
if(isErr) el.style.color='var(--red)';
|
||||
}
|
||||
var c=window.CASDOOR_CONFIG;
|
||||
if(!c){ show('تنظیمات Casdoor یافت نشد — فایل casdoor-config.js را بررسی کنید.', true); return; }
|
||||
|
||||
var q=new URLSearchParams(location.search);
|
||||
var code=q.get('code'), state=q.get('state'), errP=q.get('error');
|
||||
if(errP){ show('ورود ناموفق: '+errP, true); return; }
|
||||
if(!code){ show('کد مجوز یافت نشد.', true); return; }
|
||||
|
||||
var pkce=null;
|
||||
try{ pkce=JSON.parse(sessionStorage.getItem('casdoor-pkce')||'null'); }catch(e){}
|
||||
if(pkce&&pkce.state!==state){ show('مقدار state نامعتبر است.', true); return; }
|
||||
|
||||
function parseJwt(tok){
|
||||
var p=tok.split('.')[1];
|
||||
if(!p) return {};
|
||||
try{ return JSON.parse(decodeURIComponent(escape(atob(p.replace(/-/g,'+').replace(/_/g,'/'))))); }
|
||||
catch(e){ return {}; }
|
||||
}
|
||||
|
||||
var body=new URLSearchParams();
|
||||
body.set('grant_type','authorization_code');
|
||||
body.set('client_id', c.clientId);
|
||||
body.set('client_secret', c.clientSecret);
|
||||
body.set('code', code);
|
||||
body.set('redirect_uri', c.redirectUri);
|
||||
if(pkce&&pkce.verifier) body.set('code_verifier', pkce.verifier);
|
||||
|
||||
show('در حال تأیید هویت…');
|
||||
fetch(c.serverUrl+'/api/login/oauth/access_token', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/x-www-form-urlencoded'},
|
||||
body:body.toString()
|
||||
}).then(function(r){ return r.json(); }).then(function(t){
|
||||
if(!t.access_token){ throw new Error(t.error_description||t.error||'توکن دریافت نشد'); }
|
||||
var claims=parseJwt(t.access_token);
|
||||
var email=(claims.email||'').toLowerCase();
|
||||
var name=claims.name||claims.preferred_username||'';
|
||||
if(!email){ throw new Error('ایمیل در توکن یافت نشد'); }
|
||||
try{
|
||||
localStorage.setItem('inventory-session-v1', JSON.stringify({
|
||||
mode:'casdoor', sub:claims.sub, email:email, name:name, token:t.access_token
|
||||
}));
|
||||
sessionStorage.removeItem('casdoor-pkce');
|
||||
}catch(e){}
|
||||
var next=q.get('next')||'inventory-system.html';
|
||||
location.href=next;
|
||||
}).catch(function(e){ show('خطا در دریافت توکن: '+e.message, true); });
|
||||
})();
|
||||
</script>
|
||||
<div id="login">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/></svg>
|
||||
</div>
|
||||
<h1>سامانه مدیریت انبار</h1>
|
||||
<div class="sub">در حال بازگشت از حساب سازمانی…</div>
|
||||
<div id="msg" style="margin-top:14px; font-size:13px; font-weight:600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +0,0 @@
|
||||
// تنظیمات اتصال به Casdoor — توسط casdoor/seed.py تولید شده
|
||||
window.CASDOOR_CONFIG = {
|
||||
serverUrl: 'http://localhost:8080',
|
||||
clientId: 'inventory-app-client',
|
||||
clientSecret: 'inventory-app-secret-2026',
|
||||
organization: 'inventory',
|
||||
appName: 'inventory-app',
|
||||
redirectUri: 'http://localhost:8123/callback.html'
|
||||
};
|
||||
@@ -1,797 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>تغییر موجودی — سامانه مدیریت انبار</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=3">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ==================== LOGIN ==================== -->
|
||||
<div id="login">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/></svg>
|
||||
</div>
|
||||
<h1>سامانه مدیریت انبار</h1>
|
||||
<div class="sub">برای ورود، رمز عبور خود را وارد کنید</div>
|
||||
<label>رمز عبور</label>
|
||||
<input type="password" id="login-pass" autocomplete="off" placeholder="••••" onkeydown="if(event.key==='Enter')doLogin()">
|
||||
<button class="login-btn" onclick="doLogin()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="m10 17 5-5-5-5"/><path d="M15 12H3"/></svg>
|
||||
ورود به سامانه
|
||||
</button>
|
||||
<div class="login-divider">یا</div>
|
||||
<button class="login-btn casdoor-btn" onclick="startCasdoorLogin()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="m10 17 5-5-5-5"/><path d="M15 12H3"/></svg>
|
||||
ورود با حساب سازمانی
|
||||
</button>
|
||||
<div class="login-err" id="login-err"></div>
|
||||
<div class="demo-hint">
|
||||
<b>رمزهای آزمایشی:</b><br>
|
||||
۱ ← مدیر پروژه برج آیتا — فاز ۳ (آقای موسوی) | ۲ ← مدیر پروژه برج آیتا — فاز ۳ (آقای گلی)<br>
|
||||
۳ ← مدیر پروژه مجتمع تجاری الماس (آقای محقق) | ۱۰ ← ادمین (آقای رضایی — فقط خواندنی)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ==================== APP (صفحه تغییر موجودی) ==================== -->
|
||||
<div id="app" class="hidden">
|
||||
<div class="topbar">
|
||||
<div class="brand">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1>سامانه مدیریت انبار</h1>
|
||||
<div class="sub">دادهها در پایگاه داده ذخیره میشوند</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<a class="change-back" href="inventory-system.html" title="برگشت به داشبوردهاها">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
||||
برگشت به داشبوردها
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="sessionbar" id="sessionbar"></section>
|
||||
|
||||
|
||||
<main id="main"></main>
|
||||
</div>
|
||||
|
||||
<div id="toasts"></div>
|
||||
|
||||
<script src="casdoor-config.js?v=3"></script>
|
||||
<script src="app-config.js?v=3"></script>
|
||||
<script src="core.js?v=3"></script>
|
||||
<script>
|
||||
/* ============================================================
|
||||
|
||||
change-inventory.html — صفحه تغییر موجودی (فرمها و ثبت)
|
||||
متصل به بکاند — تراکنشها با POST به /api/v1/inventory/transactions
|
||||
بدون ناوبری سربرگها — خروج از این صفحه فقط با «برگشت به داشبوردها»
|
||||
|
||||
============================================================ */
|
||||
|
||||
window.NAV_ITEMS=[
|
||||
{id:'dashboard', label:'داشبورد انبار', ic:'box', href:'inventory-system.html'},
|
||||
{id:'change', label:'تغییر موجودی', ic:'plus', active:true, admin:false},
|
||||
{id:'calendar', label:'کنترل روزانه', ic:'calendar', href:'inventory-system.html#calendar'},
|
||||
{id:'warehouses', label:'انبارها', ic:'warehouse', href:'inventory-system.html#warehouses'},
|
||||
{id:'users', label:'سازماندهی', ic:'user', href:'inventory-system.html#users', admin:true}
|
||||
];
|
||||
|
||||
function formTypeColor(type){
|
||||
var f=FORM_TYPES.find(function(x){return x.t===type;});
|
||||
return FORM_CLS_COLOR[f?f.cls:'out'];
|
||||
}
|
||||
|
||||
/* رکوردهای موجودی انبار انتخابی برای نام ماده اولیه تایپشده */
|
||||
function formRecords(whId,matTxt){
|
||||
txt=(matTxt||'').trim();
|
||||
return state.inventory.filter(function(r){
|
||||
if(!r.warehouse||r.warehouse.id!==whId) return false;
|
||||
if(txt&&(!r.material||r.material.name.indexOf(txt)<0)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function refocus(id){
|
||||
var el=document.getElementById(id);
|
||||
if(el){ el.focus(); var L=el.value.length; try{el.setSelectionRange(L,L);}catch(e){} }
|
||||
}
|
||||
|
||||
function renderChange(){
|
||||
var main=document.getElementById('main');
|
||||
if(isAdmin()){
|
||||
main.innerHTML='<div class="readonly-block">'
|
||||
+'<div class="big-ico">'+icon('lock','')+'</div>'
|
||||
+'<h3>دسترسی فقطخواندنی</h3>'
|
||||
+'<p>نقش ادمین اجازه ثبت تغییر موجودی ندارد. این بخش برای مدیران پروژه فعال است.</p>'
|
||||
+'</div>';
|
||||
return;
|
||||
}
|
||||
var f=ui.form;
|
||||
var color=formTypeColor(f.type);
|
||||
|
||||
var typesHtml='<div class="tx-types"><h3>'+icon('box','')+'نوع تغییر موجودی</h3>'+FORM_TYPES.map(function(x){
|
||||
var c=FORM_CLS_COLOR[x.cls];
|
||||
return '<button class="tx-type-btn'+(f.type===x.t?' active':'')+'" style="--tc:'+c+';--tc-bg:'+hexBg(c,.1)+'" onclick="switchFormType(\''+x.t+'\')">'
|
||||
+icon(x.icon,'')+esc(TX[x.t]?TX[x.t].label:'انتقال')+'<span class="dir">'+esc(x.dirTxt)+'</span></button>';
|
||||
}).join('')+'</div>';
|
||||
|
||||
var formHtml=formHTMLFor(f.type,color);
|
||||
main.innerHTML='<div class="txp-layout">'+typesHtml+'<div>'+formHtml+'</div></div>';
|
||||
}
|
||||
|
||||
function switchFormType(t){
|
||||
ui.form=emptyForm(t);
|
||||
renderChange();
|
||||
}
|
||||
|
||||
function warehouseSelectHTML(id,label,selVal,options,disabled){
|
||||
return '<div class="f-row"><label>'+esc(label)+'</label>'
|
||||
+'<select id="'+id+'"'+(disabled?' disabled':'')+' onchange="ui.form.'+id.replace('f-','')+'=this.value">'
|
||||
+(selVal===''?'<option value="" selected>— انتخاب کنید —</option>':'')
|
||||
+options.map(function(w){return '<option value="'+w.id+'"'+(selVal===w.id?' selected':'')+'>'+esc(w.name)+'</option>';}).join('')
|
||||
+'</select></div>';
|
||||
}
|
||||
|
||||
function stockedMaterials(whId){
|
||||
var set={},ids=[];
|
||||
state.inventory.forEach(function(r){
|
||||
if(!r.warehouse||r.warehouse.id!==whId) return;
|
||||
if(!r.material) return;
|
||||
if(!set[r.material.id]){ set[r.material.id]=1; ids.push(r.material.id); }
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function stockedMaterialNames(whId){
|
||||
return stockedMaterials(whId).map(function(id){return matName(id);})
|
||||
.sort(function(a,b){return a.localeCompare(b,'fa');});
|
||||
}
|
||||
|
||||
function matInputHTML(whId,val,placeholder,oninputFn){
|
||||
var names=state.materials.map(function(m){return m.name;});
|
||||
return datalist('dl-mat',names)
|
||||
+'<input type="text" id="f-mat-inp" list="dl-mat" placeholder="'+esc(placeholder)+'" value="'+esc(val)+'" oninput="'+oninputFn+'(this.value)">';
|
||||
}
|
||||
|
||||
function onListMatInput(v){
|
||||
ui.form.matTxt=v;
|
||||
ui.form.rows=[];
|
||||
renderChange();
|
||||
/* بازگرداندن فوکوس و نشانگر به فیلد ماده اولیه بعد از رندر مجدد */
|
||||
refocus('f-mat-inp');
|
||||
}
|
||||
|
||||
function formHTMLFor(type,color){
|
||||
var f=ui.form;
|
||||
var meta=type==='TRANSFER'?{label:'انتقال',icon:'out',color:'#4f46e5'}:TX[type];
|
||||
var descs={
|
||||
PURCHASE:'اطلاعات کامل جنس جدید — دستهبندی از روی ماده اولیه بهصورت خودکار مشخص میشود (فقط جنس جدید دسته میخواهد). ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است.',
|
||||
WITHDRAWAL:'انبار را انتخاب و نام ماده اولیه را تایپ کنید؛ همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند. برداشتها در «کنترل روزانه» امروز دیده میشوند.',
|
||||
RETURN:'برگشت از مصالحی که امروز برداشت شدهاند — نام ماده اولیه را تایپ کنید و مقدار برگشتی هر ردیف را بزنید.',
|
||||
TRANSFER:'جابهجایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید.',
|
||||
DAMAGE:'ثبت ضایعات خرید (مثلاً لولههای سوراخ) — انبار و ماده اولیه را انتخاب و تعداد ضایعات هر مدل را وارد کنید.',
|
||||
WASTE:'اتلاف بعد از خرید (خرابی در انبار یا هدررفت در کار) — انبار و ماده اولیه را انتخاب کنید.',
|
||||
LOSS:'ثبت مفقودی — انبار و ماده اولیه را انتخاب و تعداد مفقودی هر مدل را وارد کنید.',
|
||||
FOUND:'ثبت پیدا شدن کالاهای مفقود — مفقودیهای همان انبار و ماده اولیه (یا «همه مفقودیها») نمایش داده میشود.',
|
||||
STOCK_COUNT:'شمارش فیزیکی — مقدار شمارششده هر رکورد را وارد کنید تا موجودی اصلاح شود و اختلاف ثبت گردد.'
|
||||
};
|
||||
var head='<h2 style="color:'+color+';--tc:'+color+';--tc-bg:'+hexBg(color,.1)+'">'+icon(meta.icon,'')+esc(meta.label)+'</h2>'
|
||||
+'<div class="tx-desc">'+esc(descs[type])+'</div>';
|
||||
|
||||
if(type==='PURCHASE') return head+purchaseFormHTML();
|
||||
if(type==='RETURN') return head+returnFormHTML();
|
||||
if(type==='TRANSFER') return head+transferFormHTML();
|
||||
if(type==='FOUND') return head+foundFormHTML();
|
||||
return head+listOpFormHTML(type);
|
||||
}
|
||||
|
||||
/* ---------- خرید: کامبوباکس با حافظه ---------- */
|
||||
|
||||
function purchaseCatId(){
|
||||
var m=matByName(ui.form.matTxt);
|
||||
if(m) return m.category?m.category.id:null;
|
||||
return ui.form.catId||null;
|
||||
}
|
||||
|
||||
function onPurchaseMatInput(v){
|
||||
ui.form.matTxt=v;
|
||||
ui.form.catId='';
|
||||
var m=matByName(v);
|
||||
var area=document.getElementById('pcat-area');
|
||||
if(area){
|
||||
if(!m&&v.trim()){
|
||||
area.innerHTML='<div class="f-row"><label>دستهبندی (جنس جدید) <span class="req">*</span></label>'
|
||||
+'<select id="f-catId" onchange="ui.form.catId=this.value; updateSubmitBtn()">'
|
||||
+'<option value="">— انتخاب کنید —</option>'
|
||||
+state.categories.map(function(c){return '<option value="'+c.id+'">'+esc(catName(c.id))+'</option>';}).join('')
|
||||
+'</select>'
|
||||
+'<div class="f-help" style="margin-top:5px">این ماده اولیه در فهرست نیست؛ فقط برای جنس جدید دستهبندی لازم است.</div></div>';
|
||||
}else{
|
||||
area.innerHTML='';
|
||||
}
|
||||
}
|
||||
if(m&&m.canonical_unit&&m.canonical_unit.name_fa&&!ui.form.unitTxt){
|
||||
ui.form.unitTxt=m.canonical_unit.name_fa;
|
||||
}
|
||||
updateSubmitBtn();
|
||||
}
|
||||
|
||||
function purchaseFormHTML(){
|
||||
var f=ui.form;
|
||||
var whOpts=scopedWarehouses();
|
||||
var whSel='<select id="f-whId" onchange="ui.form.whId=this.value; updateSubmitBtn()">'
|
||||
+(f.whId===''?'<option value="" selected>— انتخاب کنید —</option>':'')
|
||||
+whOpts.map(function(w){return '<option value="'+w.id+'"'+(f.whId===w.id?' selected':'')+'>'+esc(w.name)+'</option>';}).join('')
|
||||
+'</select>';
|
||||
|
||||
var matVals=state.materials.map(function(m){return m.name;});
|
||||
var brandVals=state.brands.map(function(b){return b.name;});
|
||||
var unitVals=state.units.map(function(u){return u.name_fa||u.code;});
|
||||
var skSet={},svSet={};
|
||||
state.inventory.forEach(function(r){
|
||||
Object.keys(r.specs||{}).forEach(function(key){
|
||||
skSet[key]=1; svSet[r.specs[key]]=1;
|
||||
});
|
||||
});
|
||||
var specK=Object.keys(skSet);
|
||||
var specV=Object.keys(svSet);
|
||||
|
||||
var specRows=ui.form.specsRows.map(function(r){
|
||||
return '<div class="spec-row">'
|
||||
+'<input type="text" class="k" list="dl-sk" placeholder="نام مشخصه (مثلاً سایز)" value="'+esc(r.label)+'" oninput="onSpecLabel('+r.rid+',this.value)">'
|
||||
+'<input type="text" class="v" list="dl-sv" placeholder="مقدار (مثلاً ۱۶)" value="'+esc(r.value)+'" oninput="onSpecValue('+r.rid+',this.value)">'
|
||||
+'<button type="button" class="spec-del" onclick="removeSpecRow('+r.rid+')" title="حذف">'+icon('x','')+'</button>'
|
||||
+'</div>';
|
||||
}).join('');
|
||||
|
||||
return datalist('dl-brand',brandVals)
|
||||
+datalist('dl-unit',unitVals)+datalist('dl-sk',specK)+datalist('dl-sv',specV)
|
||||
+'<div class="f-grid">'
|
||||
+'<div class="f-row"><label>انبار <span class="req">*</span></label>'+whSel+'</div>'
|
||||
+'<div class="f-row"><label>ماده اولیه <span class="req">*</span></label>'
|
||||
+'<input type="text" list="dl-mat" placeholder="نام ماده اولیه — از پیشنهادها یا جدید…" value="'+esc(f.matTxt)+'" oninput="onPurchaseMatInput(this.value)"></div>'
|
||||
+'<div class="f-row"><label>برند <span class="req">*</span> <span class="lbl-hint">الزامی</span></label>'
|
||||
+'<input type="text" list="dl-brand" placeholder="نام برند — از پیشنهادها یا جدید…" value="'+esc(f.brandTxt)+'" oninput="ui.form.brandTxt=this.value; updateSubmitBtn()"></div>'
|
||||
+'<div class="f-row"><label>واحد <span class="req">*</span></label>'
|
||||
+'<input type="text" list="dl-unit" placeholder="واحد اندازهگیری…" value="'+esc(f.unitTxt)+'" oninput="ui.form.unitTxt=this.value; updateSubmitBtn()"></div>'
|
||||
+'<div class="f-row"><label>تعداد <span class="req">*</span></label>'
|
||||
+'<input type="text" inputmode="decimal" class="ltr" placeholder="مثلاً ۲۰۰" value="'+esc(f.qty)+'" oninput="ui.form.qty=this.value; updateSubmitBtn()"></div>'
|
||||
+'<div class="f-row"><label>تاریخ و زمان</label>'
|
||||
+'<input type="datetime-local" class="ltr" value="'+esc(f.dateInput)+'" onchange="ui.form.dateInput=this.value"></div>'
|
||||
+'</div>'
|
||||
+'<div id="pcat-area" class="pcat-wrap"></div>'
|
||||
+'<div class="f-row"><label>مشخصههای فنی <span class="req">*</span> <span class="lbl-hint">الزامی</span></label>'
|
||||
+'<div class="spec-box"><div class="spec-rows" id="spec-rows">'+specRows+'</div>'
|
||||
+'<button type="button" class="add-spec" onclick="addSpecRow()">'+icon('plus','')+'افزودن مشخصه فنی</button></div></div>'
|
||||
+'<div class="f-row"><label>یادداشت</label>'
|
||||
+'<textarea placeholder="شماره فاکتور، تأمینکننده، توضیحات…" oninput="ui.form.note=this.value">'+esc(f.note)+'</textarea></div>'
|
||||
+submitBar('PURCHASE','ثبت خرید',purchaseCanSubmit());
|
||||
}
|
||||
|
||||
function addSpecRow(){
|
||||
ui.form.specsRows.push({rid:ui.form.specSeq++,label:'',value:''});
|
||||
renderSpecRows();
|
||||
updateSubmitBtn();
|
||||
}
|
||||
function renderSpecRows(){
|
||||
var el=document.getElementById('spec-rows');
|
||||
if(!el) return;
|
||||
el.innerHTML=ui.form.specsRows.map(function(r){
|
||||
return '<div class="spec-row">'
|
||||
+'<input type="text" class="k" list="dl-sk" placeholder="نام مشخصه" value="'+esc(r.label)+'" oninput="onSpecLabel('+r.rid+',this.value)">'
|
||||
+'<input type="text" class="v" list="dl-sv" placeholder="مقدار" value="'+esc(r.value)+'" oninput="onSpecValue('+r.rid+',this.value)">'
|
||||
+'<button type="button" class="spec-del" onclick="removeSpecRow('+r.rid+')" title="حذف">'+icon('x','')+'</button>'
|
||||
+'</div>';
|
||||
}).join('');
|
||||
}
|
||||
function removeSpecRow(rid){
|
||||
ui.form.specsRows=ui.form.specsRows.filter(function(r){return r.rid!==rid;});
|
||||
if(!ui.form.specsRows.length) ui.form.specsRows=[{rid:ui.form.specSeq++,label:'',value:''}];
|
||||
renderSpecRows();
|
||||
updateSubmitBtn();
|
||||
}
|
||||
function onSpecLabel(rid,v){
|
||||
var r=ui.form.specsRows.find(function(x){return x.rid===rid;});
|
||||
if(r) r.label=v;
|
||||
updateSubmitBtn();
|
||||
}
|
||||
function onSpecValue(rid,v){
|
||||
var r=ui.form.specsRows.find(function(x){return x.rid===rid;});
|
||||
if(r) r.value=v;
|
||||
updateSubmitBtn();
|
||||
}
|
||||
function specsFromRows(){
|
||||
var specs={};
|
||||
ui.form.specsRows.forEach(function(r){
|
||||
if(r.label.trim()&&r.value.trim()) specs[r.label.trim()]=r.value.trim();
|
||||
});
|
||||
return specs;
|
||||
}
|
||||
function specsRowsComplete(){
|
||||
var has=false;
|
||||
ui.form.specsRows.forEach(function(r){
|
||||
if(r.label.trim()&&r.value.trim()) has=true;
|
||||
});
|
||||
return has;
|
||||
}
|
||||
function purchaseCanSubmit(){
|
||||
var f=ui.form;
|
||||
return !!(f.whId&&matByName(f.matTxt)&&brandIdByName(f.brandTxt)&&unitIdByName(f.unitTxt)
|
||||
&&parseNum(f.qty)>0&&specsRowsComplete());
|
||||
}
|
||||
|
||||
function unitIdByName(txt){
|
||||
txt=(txt||'').trim();
|
||||
var u=state.units.find(function(x){return (x.name_fa||x.code)===txt;});
|
||||
return u?u.id:null;
|
||||
}
|
||||
|
||||
function submitPurchase(){
|
||||
var f=ui.form;
|
||||
var m=matByName(f.matTxt);
|
||||
var bid=brandIdByName(f.brandTxt);
|
||||
var uid=unitIdByName(f.unitTxt);
|
||||
var qty=parseNum(f.qty);
|
||||
var catId=purchaseCatId();
|
||||
if(!f.whId){ toast('انبار را انتخاب کنید','err'); return; }
|
||||
if(!m){ toast('ماده اولیه در فهرست نیست — نام را دقیق تایپ کنید','err'); return; }
|
||||
if(!catId){ toast('برای جنس جدید، دستهبندی را انتخاب کنید','err'); return; }
|
||||
if(!bid){ toast('برند را انتخاب کنید','err'); return; }
|
||||
if(!uid){ toast('واحد را انتخاب کنید','err'); return; }
|
||||
if(!(qty>0)){ toast('تعداد باید بزرگتر از صفر باشد','err'); return; }
|
||||
if(!specsRowsComplete()){ toast('حداقل یک مشخصه فنی (نام و مقدار) ثبت کنید','err'); return; }
|
||||
var payload={
|
||||
transaction_type:'PURCHASE',
|
||||
warehouse_id:f.whId,
|
||||
material_id:m.id,
|
||||
brand_id:bid,
|
||||
quantity:qty,
|
||||
unit_id:uid,
|
||||
specs:specsFromRows(),
|
||||
note:f.note,
|
||||
transaction_date:new Date(f.dateInput).toISOString()
|
||||
};
|
||||
apiPost('/inventory/transactions', payload)
|
||||
.then(function(){
|
||||
toast('خرید ثبت شد ✓','ok');
|
||||
ui.form=emptyForm('PURCHASE');
|
||||
return refreshData();
|
||||
}).catch(function(e){ toast(e.message,'err'); });
|
||||
}
|
||||
|
||||
/* ---------- عملیات لیستی (برداشت/ضایعات/اتلاف/مفقودی/شمارش) ---------- */
|
||||
|
||||
function listOpFormHTML(type){
|
||||
var f=ui.form;
|
||||
var whOpts=scopedWarehouses();
|
||||
var whSel='<select id="f-whId" onchange="ui.form.whId=this.value; ui.form.matTxt=\'\'; ui.form.rows=[]; renderChange()">'
|
||||
+(f.whId===''?'<option value="" selected>— انتخاب کنید —</option>':'')
|
||||
+whOpts.map(function(w){return '<option value="'+w.id+'"'+(f.whId===w.id?' selected':'')+'>'+esc(w.name)+'</option>';}).join('')
|
||||
+'</select>';
|
||||
|
||||
var matInp=matInputHTML(f.whId,f.matTxt,'نام ماده اولیه را تایپ کنید — همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند…','onListMatInput');
|
||||
|
||||
var rowsHtml=rowsTableHTML(f.whId,f.matTxt,type);
|
||||
|
||||
return '<div class="f-grid">'
|
||||
+'<div class="f-row"><label>انبار <span class="req">*</span></label>'+whSel+'</div>'
|
||||
+'<div class="f-row"><label>ماده اولیه <span class="req">*</span></label>'+matInp+'</div>'
|
||||
+'</div>'
|
||||
+'<div class="hint-box">'+icon('info','')+' ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت (در صورت نیاز) وارد کنید.</div>'
|
||||
+rowsHtml
|
||||
+submitBar(type,TX[type].label+' (بر اساس ردیفهای پر شده)',listCanSubmit(type));
|
||||
}
|
||||
|
||||
function rowsTableHTML(whId,matTxt,type){
|
||||
var f=ui.form;
|
||||
var recs=formRecords(whId,matTxt);
|
||||
var txt=(matTxt||'').trim();
|
||||
if(!whId) return '<div class="rows-empty">برای نمایش رکوردها، ابتدا انبار را انتخاب کنید.</div>';
|
||||
if(!txt) return '<div class="rows-empty">نام ماده اولیه را تایپ کنید تا همه مدلهای آن (با برند و مشخصه متفاوت) نمایش داده شود.</div>';
|
||||
if(!recs.length) return '<div class="rows-empty">برای این نام ماده اولیه در انبار انتخابی رکوردی وجود ندارد. ابتدا خرید ثبت کنید.</div>';
|
||||
if(!f.rows.length){
|
||||
f.rows=recs.map(function(r){return {key:r.id,qty:'',note:''};});
|
||||
}
|
||||
var isCount=type==='STOCK_COUNT';
|
||||
var body='';
|
||||
recs.forEach(function(r,i){
|
||||
var row=f.rows[i]||{key:r.id,qty:'',note:''};
|
||||
body+='<tr>'
|
||||
+'<td>'+(specsChips(r.specs)||'—')+'</td>'
|
||||
+'<td>'+esc(r.brand?r.brand.name:'—')+'</td>'
|
||||
+'<td>'+esc(r.unit?(r.unit.name_fa||r.unit.code):'—')+'</td>'
|
||||
+'<td><span class="cur">'+fa(r.quantity_number)+'</span></td>'
|
||||
+'<td><input class="qty" type="text" inputmode="decimal" placeholder="'+(isCount?'شمارششده':'۰')+'" value="'+esc(isCount&&row.qty===''?r.quantity_number:row.qty)+'" oninput="rowQty('+i+',this.value,'+(isCount?1:0)+')"></td>'
|
||||
+'<td><input class="note" type="text" placeholder="توضیح (اختیاری)" value="'+esc(row.note)+'" oninput="rowNote('+i+',this.value)"></td>'
|
||||
+'</tr>';
|
||||
});
|
||||
return '<div class="tbl-wrap"><table class="rows-tbl"><thead><tr>'
|
||||
+'<th>مشخصهها</th><th>برند</th><th>واحد</th><th>موجودی فعلی</th><th>'+(isCount?'شمارششده':'تعداد')+'</th><th>یادداشت</th>'
|
||||
+'</tr></thead><tbody>'+body+'</tbody></table></div>';
|
||||
}
|
||||
|
||||
function rowQty(i,v,isCount){
|
||||
if(ui.form.rows[i]) ui.form.rows[i].qty=v;
|
||||
updateSubmitBtn();
|
||||
}
|
||||
function rowNote(i,v){ if(ui.form.rows[i]) ui.form.rows[i].note=v; }
|
||||
|
||||
function listCanSubmit(type){
|
||||
var f=ui.form;
|
||||
if(!f.whId||!f.matTxt.trim()||!f.rows.length) return false;
|
||||
return f.rows.some(function(r){ return parseNum(r.qty)>0; });
|
||||
}
|
||||
|
||||
function submitList(type){
|
||||
var f=ui.form;
|
||||
var isCount=type==='STOCK_COUNT';
|
||||
var ops=[];
|
||||
f.rows.forEach(function(r){
|
||||
var n=parseNum(r.qty);
|
||||
if(!(n>0)) return;
|
||||
var rec=recById(r.key);
|
||||
if(!rec) return;
|
||||
if(isCount){
|
||||
ops.push({transaction_type:'STOCK_COUNT', warehouse_id:f.whId, material_id:rec.material.id,
|
||||
brand_id:rec.brand?rec.brand.id:null, physical_qty:n, note:r.note,
|
||||
transaction_date:new Date(f.dateInput).toISOString()});
|
||||
}else{
|
||||
ops.push({transaction_type:type, warehouse_id:f.whId, material_id:rec.material.id,
|
||||
brand_id:rec.brand?rec.brand.id:null, quantity:n, unit_id:rec.unit?rec.unit.id:null,
|
||||
specs:rec.specs||{}, note:r.note, transaction_date:new Date(f.dateInput).toISOString()});
|
||||
}
|
||||
});
|
||||
if(!ops.length){ toast('هیچ ردیفی پر نشده است','err'); return; }
|
||||
var seq=ops.slice();
|
||||
var run=function(){
|
||||
var op=seq.shift();
|
||||
if(!op){ toast('ثبت شد ✓','ok'); ui.form=emptyForm(type); return refreshData(); }
|
||||
return apiPost('/inventory/transactions', op).then(run).catch(function(e){ toast(e.message,'err'); });
|
||||
};
|
||||
run();
|
||||
}
|
||||
|
||||
/* ---------- برگشت ---------- */
|
||||
|
||||
function todayWithdrawals(whId,matTxt){
|
||||
txt=(matTxt||'').trim();
|
||||
var tk=todayKey();
|
||||
return state.transactions.filter(function(t){
|
||||
if(t.transaction_type!=='WITHDRAWAL'||t.warehouse_id!==whId) return false;
|
||||
if(gregKey(new Date(t.transaction_date))!==tk) return false;
|
||||
if(txt&&(!t.material||t.material.name.indexOf(txt)<0)) return false;
|
||||
return true;
|
||||
}).sort(function(a,b){return new Date(a.transaction_date)-new Date(b.transaction_date);});
|
||||
}
|
||||
|
||||
function returnFormHTML(){
|
||||
var f=ui.form;
|
||||
var whOpts=scopedWarehouses();
|
||||
var whSel='<select id="f-whId" onchange="ui.form.whId=this.value; ui.form.matTxt=\'\'; ui.form.rows=[]; renderChange()">'
|
||||
+(f.whId===''?'<option value="" selected>— انتخاب کنید —</option>':'')
|
||||
+whOpts.map(function(w){return '<option value="'+w.id+'"'+(f.whId===w.id?' selected':'')+'>'+esc(w.name)+'</option>';}).join('')
|
||||
+'</select>';
|
||||
var matInp=matInputHTML(f.whId,f.matTxt,'نام ماده اولیه برداشتشده امروز…','onListMatInput');
|
||||
|
||||
var html='<div class="f-grid">'
|
||||
+'<div class="f-row"><label>انبار <span class="req">*</span></label>'+whSel+'</div>'
|
||||
+'<div class="f-row"><label>ماده اولیه <span class="req">*</span></label>'+matInp+'</div>'
|
||||
+'</div>';
|
||||
|
||||
var txs=[]; var rowsHtml;
|
||||
if(f.whId&&f.matTxt.trim()){
|
||||
txs=todayWithdrawals(f.whId,f.matTxt);
|
||||
if(!txs.length){
|
||||
rowsHtml='<div class="rows-empty">امروز برداشتی برای این ماده اولیه در انبار انتخابی ثبت نشده است — چیزی برای برگشت وجود ندارد.</div>';
|
||||
}else{
|
||||
if(!f.rows.length){
|
||||
f.rows=txs.map(function(t){return {key:t.inventory_id,qty:'',note:''};});
|
||||
}
|
||||
var body='';
|
||||
txs.forEach(function(t,i){
|
||||
var row=f.rows[i]||{key:t.inventory_id,qty:'',note:''};
|
||||
body+='<tr>'
|
||||
+'<td>'+(specsChips(t.specs)||'—')+'</td>'
|
||||
+'<td>'+esc(t.brand&&t.brand.name?t.brand.name:'—')+'</td>'
|
||||
+'<td>'+esc(t.unit?(t.unit.name_fa||t.unit.code):'—')+'</td>'
|
||||
+'<td><span class="cur">'+fa(Math.abs(Number(t.quantity_number)||0))+'</span></td>'
|
||||
+'<td><input class="qty" type="text" inputmode="decimal" placeholder="۰" value="'+esc(row.qty)+'" oninput="rowQty('+i+',this.value,0)"></td>'
|
||||
+'<td><input class="note" type="text" placeholder="توضیح برگشت" value="'+esc(row.note)+'" oninput="rowNote('+i+',this.value)"></td>'
|
||||
+'</tr>';
|
||||
});
|
||||
rowsHtml='<div class="tbl-wrap"><table class="rows-tbl"><thead><tr>'
|
||||
+'<th>مشخصهها</th><th>برند</th><th>واحد</th><th>برداشت امروز</th><th>تعداد برگشتی</th><th>یادداشت</th>'
|
||||
+'</tr></thead><tbody>'+body+'</tbody></table></div>';
|
||||
}
|
||||
}else{
|
||||
rowsHtml='<div class="rows-empty">برای مشاهده برداشتهای امروز، انبار و نام ماده اولیه را وارد کنید.</div>';
|
||||
}
|
||||
|
||||
return html+rowsHtml+submitBar('RETURN','ثبت برگشت',returnCanSubmit());
|
||||
}
|
||||
|
||||
function returnCanSubmit(){
|
||||
var f=ui.form;
|
||||
if(!f.whId||!f.matTxt.trim()||!f.rows.length) return false;
|
||||
return f.rows.some(function(r){ return parseNum(r.qty)>0; });
|
||||
}
|
||||
|
||||
function submitReturn(){
|
||||
var f=ui.form;
|
||||
var txs=todayWithdrawals(f.whId,f.matTxt);
|
||||
var ops=[];
|
||||
f.rows.forEach(function(r,i){
|
||||
var n=parseNum(r.qty);
|
||||
if(!(n>0)) return;
|
||||
var t=txs[i];
|
||||
if(!t) return;
|
||||
ops.push({transaction_type:'RETURN', warehouse_id:f.whId, material_id:t.material.id,
|
||||
brand_id:t.brand?t.brand.id:null, quantity:n, unit_id:t.unit?t.unit.id:null,
|
||||
specs:t.specs||{}, note:r.note, transaction_date:new Date(f.dateInput).toISOString()});
|
||||
});
|
||||
if(!ops.length){ toast('هیچ ردیفی پر نشده است','err'); return; }
|
||||
var seq=ops.slice();
|
||||
var run=function(){
|
||||
var op=seq.shift();
|
||||
if(!op){ toast('برگشت ثبت شد ✓','ok'); ui.form=emptyForm('RETURN'); return refreshData(); }
|
||||
return apiPost('/inventory/transactions', op).then(run).catch(function(e){ toast(e.message,'err'); });
|
||||
};
|
||||
run();
|
||||
}
|
||||
|
||||
/* ---------- انتقال ---------- */
|
||||
|
||||
function transferFormHTML(){
|
||||
var f=ui.form;
|
||||
var projWhs=scopedWarehouses();
|
||||
var fromOpts=projWhs;
|
||||
var toOpts=projWhs.filter(function(w){return w.id!==f.fromId;});
|
||||
|
||||
var fromSel='<select id="f-fromId" onchange="ui.form.fromId=this.value; ui.form.toId=\'\'; ui.form.matTxt=\'\'; ui.form.rows=[]; renderChange()">'
|
||||
+(f.fromId===''?'<option value="" selected>— انتخاب انبار مبدأ —</option>':'')
|
||||
+fromOpts.map(function(w){return '<option value="'+w.id+'"'+(f.fromId===w.id?' selected':'')+'>'+esc(w.name)+'</option>';}).join('')
|
||||
+'</select>';
|
||||
var toSel='<select id="f-toId" '+(f.fromId?'':'disabled')+' onchange="ui.form.toId=this.value; renderChange()">'
|
||||
+'<option value="">— انتخاب انبار مقصد —</option>'
|
||||
+toOpts.map(function(w){return '<option value="'+w.id+'"'+(f.toId===w.id?' selected':'')+'>'+esc(w.name)+'</option>';}).join('')
|
||||
+'</select>';
|
||||
|
||||
var matInp=matInputHTML(f.fromId,f.matTxt,'نام ماده اولیه را تایپ کنید — همه مدلها نمایش داده میشوند…','onListMatInput');
|
||||
|
||||
var html='<div class="fromto">'
|
||||
+'<div class="box"><div class="lbl">'+icon('out','')+'از</div>'+fromSel+'</div>'
|
||||
+'<div class="box"><div class="lbl">'+icon('in','')+'به</div>'+toSel+'</div>'
|
||||
+'</div>'
|
||||
+'<div class="f-row" style="margin-top:12px"><label>ماده اولیه <span class="req">*</span></label>'+matInp+'</div>';
|
||||
|
||||
var rowsHtml;
|
||||
if(f.fromId&&f.toId&&f.matTxt.trim()){
|
||||
var recs=formRecords(f.fromId,f.matTxt);
|
||||
if(!recs.length){
|
||||
rowsHtml='<div class="rows-empty">در انبار مبدأ برای این ماده اولیه موجودی نیست.</div>';
|
||||
}else{
|
||||
if(!f.rows.length){
|
||||
f.rows=recs.map(function(r){return {key:r.id,qty:'',note:''};});
|
||||
}
|
||||
var body='';
|
||||
recs.forEach(function(r,i){
|
||||
var row=f.rows[i]||{key:r.id,qty:'',note:''};
|
||||
body+='<tr>'
|
||||
+'<td>'+(specsChips(r.specs)||'—')+'</td>'
|
||||
+'<td>'+esc(r.brand?r.brand.name:'—')+'</td>'
|
||||
+'<td>'+esc(r.unit?(r.unit.name_fa||r.unit.code):'—')+'</td>'
|
||||
+'<td><span class="cur">'+fa(r.quantity_number)+'</span></td>'
|
||||
+'<td><input class="qty" type="text" inputmode="decimal" placeholder="۰" value="'+esc(row.qty)+'" oninput="rowQty('+i+',this.value,0)"></td>'
|
||||
+'<td><input class="note" type="text" placeholder="توضیح (اختیاری)" value="'+esc(row.note)+'" oninput="rowNote('+i+',this.value)"></td>'
|
||||
+'</tr>';
|
||||
});
|
||||
rowsHtml='<div class="tbl-wrap"><table class="rows-tbl"><thead><tr>'
|
||||
+'<th>مشخصهها</th><th>برند</th><th>واحد</th><th>موجودی در مبدأ</th><th>تعداد انتقال</th><th>یادداشت</th>'
|
||||
+'</tr></thead><tbody>'+body+'</tbody></table></div>';
|
||||
}
|
||||
}else{
|
||||
rowsHtml='<div class="rows-empty">برای نمایش موجودیها، انبار مبدأ، مقصد و نام ماده اولیه را وارد کنید.</div>';
|
||||
}
|
||||
|
||||
return html
|
||||
+'<div class="hint-box">'+icon('info','')+' انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد همزمان ثبت میشود.</div>'
|
||||
+rowsHtml
|
||||
+submitBar('TRANSFER','ثبت انتقال',transferCanSubmit());
|
||||
}
|
||||
|
||||
function transferCanSubmit(){
|
||||
var f=ui.form;
|
||||
if(!f.fromId||!f.toId||!f.matTxt.trim()||!f.rows.length) return false;
|
||||
return f.rows.some(function(r){ return parseNum(r.qty)>0; });
|
||||
}
|
||||
|
||||
function submitTransfer(){
|
||||
var f=ui.form;
|
||||
var recs=formRecords(f.fromId,f.matTxt);
|
||||
var ops=[];
|
||||
f.rows.forEach(function(r,i){
|
||||
var n=parseNum(r.qty);
|
||||
if(!(n>0)) return;
|
||||
var rec=recs[i];
|
||||
if(!rec) return;
|
||||
ops.push({transaction_type:'TRANSFER_OUT', warehouse_id:f.fromId, dest_warehouse_id:f.toId,
|
||||
material_id:rec.material.id, brand_id:rec.brand?rec.brand.id:null, quantity:n,
|
||||
unit_id:rec.unit?rec.unit.id:null, specs:rec.specs||{}, note:r.note,
|
||||
transaction_date:new Date(f.dateInput).toISOString()});
|
||||
});
|
||||
if(!ops.length){ toast('هیچ ردیفی پر نشده است','err'); return; }
|
||||
var seq=ops.slice();
|
||||
var run=function(){
|
||||
var op=seq.shift();
|
||||
if(!op){ toast('انتقال ثبت شد ✓','ok'); ui.form=emptyForm('TRANSFER'); return refreshData(); }
|
||||
return apiPost('/inventory/transactions', op).then(run).catch(function(e){ toast(e.message,'err'); });
|
||||
};
|
||||
run();
|
||||
}
|
||||
|
||||
/* ---------- پیدا شده ---------- */
|
||||
|
||||
function lossTxsFor(whId,matTxt){
|
||||
txt=(matTxt||'').trim();
|
||||
return state.transactions.filter(function(t){
|
||||
if(t.transaction_type!=='LOSS'||t.warehouse_id!==whId) return false;
|
||||
if(txt&&(!t.material||t.material.name.indexOf(txt)<0)) return false;
|
||||
return true;
|
||||
}).sort(function(a,b){return new Date(a.transaction_date)-new Date(b.transaction_date);});
|
||||
}
|
||||
|
||||
function foundFormHTML(){
|
||||
var f=ui.form;
|
||||
var whOpts=scopedWarehouses();
|
||||
var whSel='<select id="f-whId" onchange="ui.form.whId=this.value; ui.form.matTxt=\'\'; ui.form.rows=[]; renderChange()">'
|
||||
+(f.whId===''?'<option value="" selected>— انتخاب کنید —</option>':'')
|
||||
+whOpts.map(function(w){return '<option value="'+w.id+'"'+(f.whId===w.id?' selected':'')+'>'+esc(w.name)+'</option>';}).join('')
|
||||
+'</select>';
|
||||
var matInp=matInputHTML(f.whId,f.matTxt,'نام ماده اولیه — خالی بماند = همه مفقودیها…','onListMatInput');
|
||||
|
||||
var html='<div class="f-grid">'
|
||||
+'<div class="f-row"><label>انبار <span class="req">*</span></label>'+whSel+'</div>'
|
||||
+'<div class="f-row"><label>ماده اولیه (خالی = همه مفقودیها)</label>'+matInp+'</div>'
|
||||
+'</div>';
|
||||
|
||||
var txs=[],rowsHtml;
|
||||
if(f.whId){
|
||||
txs=lossTxsFor(f.whId,f.matTxt);
|
||||
if(!txs.length){
|
||||
rowsHtml='<div class="rows-empty">مفقودی ثبتنشدهای (یا قابل پیداشدن) در این انبار وجود ندارد.</div>';
|
||||
}else{
|
||||
if(!f.rows.length){
|
||||
f.rows=txs.map(function(t){return {key:t.inventory_id,qty:'',note:''};});
|
||||
}
|
||||
var body='';
|
||||
txs.forEach(function(t,i){
|
||||
var row=f.rows[i]||{key:t.inventory_id,qty:'',note:''};
|
||||
body+='<tr>'
|
||||
+'<td>'+esc(t.material&&t.material.name?t.material.name:'—')+'</td>'
|
||||
+'<td>'+(specsChips(t.specs)||'—')+'</td>'
|
||||
+'<td>'+esc(t.brand&&t.brand.name?t.brand.name:'—')+'</td>'
|
||||
+'<td>'+esc(t.unit?(t.unit.name_fa||t.unit.code):'—')+'</td>'
|
||||
+'<td><span class="cur">'+fa(Math.abs(Number(t.quantity_number)||0))+'</span></td>'
|
||||
+'<td><input class="qty" type="text" inputmode="decimal" placeholder="۰" value="'+esc(row.qty)+'" oninput="rowQty('+i+',this.value,0)"></td>'
|
||||
+'<td><input class="note" type="text" placeholder="توضیح" value="'+esc(row.note)+'" oninput="rowNote('+i+',this.value)"></td>'
|
||||
+'</tr>';
|
||||
});
|
||||
rowsHtml='<div class="tbl-wrap"><table class="rows-tbl"><thead><tr>'
|
||||
+'<th>ماده اولیه</th><th>مشخصهها</th><th>برند</th><th>واحد</th><th>مفقودی</th><th>تعداد پیداشده</th><th>یادداشت</th>'
|
||||
+'</tr></thead><tbody>'+body+'</tbody></table></div>';
|
||||
}
|
||||
}else{
|
||||
rowsHtml='<div class="rows-empty">ابتدا انبار را انتخاب کنید.</div>';
|
||||
}
|
||||
|
||||
return html+rowsHtml+submitBar('FOUND','ثبت پیدا شدن',foundCanSubmit());
|
||||
}
|
||||
|
||||
function foundCanSubmit(){
|
||||
var f=ui.form;
|
||||
if(!f.whId||!f.rows.length) return false;
|
||||
return f.rows.some(function(r){ return parseNum(r.qty)>0; });
|
||||
}
|
||||
|
||||
function submitFound(){
|
||||
var f=ui.form;
|
||||
var txs=lossTxsFor(f.whId,f.matTxt);
|
||||
var ops=[];
|
||||
f.rows.forEach(function(r,i){
|
||||
var n=parseNum(r.qty);
|
||||
if(!(n>0)) return;
|
||||
var t=txs[i];
|
||||
if(!t) return;
|
||||
ops.push({transaction_type:'FOUND', warehouse_id:f.whId, material_id:t.material.id,
|
||||
brand_id:t.brand?t.brand.id:null, quantity:n, unit_id:t.unit?t.unit.id:null,
|
||||
specs:t.specs||{}, note:r.note, transaction_date:new Date(f.dateInput).toISOString()});
|
||||
});
|
||||
if(!ops.length){ toast('هیچ ردیفی پر نشده است','err'); return; }
|
||||
var seq=ops.slice();
|
||||
var run=function(){
|
||||
var op=seq.shift();
|
||||
if(!op){ toast('پیدا شدن ثبت شد ✓','ok'); ui.form=emptyForm('FOUND'); return refreshData(); }
|
||||
return apiPost('/inventory/transactions', op).then(run).catch(function(e){ toast(e.message,'err'); });
|
||||
};
|
||||
run();
|
||||
}
|
||||
|
||||
/* ---------- اجزای مشترک فرم ---------- */
|
||||
|
||||
function onFormSelChanged(){ renderChange(); }
|
||||
|
||||
function submitBar(type,label,enabled){
|
||||
var color=formTypeColor(type);
|
||||
return '<div class="submit-bar">'
|
||||
+'<span class="actor">'+icon('user','')+'ثبت با نام: <b>'+esc(user().name)+'</b></span>'
|
||||
+'<button class="submit-btn" id="f-submit" style="--tc:'+color+'" '+(enabled?'':'disabled')+' onclick="doSubmit(\''+type+'\')">'
|
||||
+icon('check','')+esc(label)+'</button>'
|
||||
+'</div>';
|
||||
}
|
||||
|
||||
function updateSubmitBtn(){
|
||||
var btn=document.getElementById('f-submit');
|
||||
if(!btn) return;
|
||||
var f=ui.form;
|
||||
var ok=false;
|
||||
if(f.type==='PURCHASE') ok=purchaseCanSubmit();
|
||||
else if(f.type==='RETURN') ok=returnCanSubmit();
|
||||
else if(f.type==='TRANSFER') ok=transferCanSubmit();
|
||||
else if(f.type==='FOUND') ok=foundCanSubmit();
|
||||
else ok=listCanSubmit(f.type);
|
||||
btn.disabled=!ok;
|
||||
}
|
||||
|
||||
function doSubmit(type){
|
||||
if(type==='PURCHASE') submitPurchase();
|
||||
else if(type==='RETURN') submitReturn();
|
||||
else if(type==='TRANSFER') submitTransfer();
|
||||
else if(type==='FOUND') submitFound();
|
||||
else submitList(type);
|
||||
}
|
||||
|
||||
/* پیشپر کردن فرم از پارامترهای URL (پرش از کنترل روزانه) */
|
||||
function applyQuickPrefill(){
|
||||
if(typeof location==='undefined'||!location.search) return;
|
||||
var p=new URLSearchParams(location.search);
|
||||
var t=p.get('type'), w=p.get('wh'), m=p.get('mat');
|
||||
if(t&&TX[t]){
|
||||
ui.form=emptyForm(t);
|
||||
if(w) ui.form.whId=w;
|
||||
if(m) ui.form.matTxt=m;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
boot
|
||||
============================================================ */
|
||||
|
||||
loadUsers();
|
||||
var session=loadSession();
|
||||
if(session){
|
||||
document.getElementById('login').classList.add('hidden');
|
||||
document.getElementById('app').classList.remove('hidden');
|
||||
renderChrome();
|
||||
applyQuickPrefill();
|
||||
loadAll().then(function(ok){ renderChrome(); renderChange(); });
|
||||
}else{
|
||||
document.getElementById('app').classList.add('hidden');
|
||||
document.getElementById('login').classList.remove('hidden');
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,582 +0,0 @@
|
||||
/* ============================================================
|
||||
core.js — بخش مشترک سامانه مدیریت انبار (نسخه متصل به API)
|
||||
(ثابتها، هویت/نشست، لایه API، نوار بالا، توست)
|
||||
بارگذاری مشترک در inventory-system.html و change-inventory.html
|
||||
دادهها از بکاند (FastAPI /api/v1) خوانده میشوند.
|
||||
============================================================ */
|
||||
|
||||
/* ---------- متریالها و فهرستها از API میآیند ---------- */
|
||||
var API_BASE = (typeof window!=='undefined' && window.APP_CONFIG && window.APP_CONFIG.apiBase) || '/api/v1';
|
||||
|
||||
var CATEGORY_FA = {
|
||||
'mechanical-installations-pipes': 'تأسیسات مکانیکی و آبی',
|
||||
'electrical-equipment-supplies': 'تجهیزات و لوازم برقی',
|
||||
'doors-windows-glass': 'درب، پنجره و شیشه',
|
||||
'metals-steel-sections': 'فلزات و مقاطع فولادی',
|
||||
'construction-materials': 'مصالح ساختمانی',
|
||||
'construction-chemicals': 'مواد شیمیایی ساختمانی',
|
||||
'finishing-interior-decoration': 'نازککاری و دکوراسیون داخلی',
|
||||
'construction-tools-equipment': 'ابزار و تجهیزات کارگاهی'
|
||||
};
|
||||
|
||||
var TX = {
|
||||
PURCHASE: {label:'خرید', icon:'cart', color:'#16a34a', dir:'+', grp:'in'},
|
||||
WITHDRAWAL: {label:'برداشت', icon:'minusCirc', color:'#1d4ed8', dir:'−', grp:'out'},
|
||||
RETURN: {label:'برگشت', icon:'undo', color:'#16a34a', dir:'+', grp:'in'},
|
||||
TRANSFER_IN: {label:'انتقال (ورودی)', icon:'in', color:'#16a34a', dir:'+', grp:'in'},
|
||||
TRANSFER_OUT:{label:'انتقال (خروجی)', icon:'out', color:'#1d4ed8', dir:'−', grp:'out'},
|
||||
DAMAGE: {label:'ضایعات', icon:'warn', color:'#1d4ed8', dir:'−', grp:'out'},
|
||||
WASTE: {label:'اتلاف', icon:'trash', color:'#1d4ed8', dir:'−', grp:'out'},
|
||||
LOSS: {label:'مفقودی', icon:'pkgX', color:'#1d4ed8', dir:'−', grp:'out'},
|
||||
FOUND: {label:'پیدا شده', icon:'search', color:'#16a34a', dir:'+', grp:'in'},
|
||||
STOCK_COUNT: {label:'شمارش موجودی', icon:'check', color:'#64748b', dir:'±', grp:'cnt'}
|
||||
};
|
||||
var TX_ORDER = ['PURCHASE','WITHDRAWAL','RETURN','TRANSFER_IN','TRANSFER_OUT','DAMAGE','WASTE','LOSS','FOUND','STOCK_COUNT'];
|
||||
|
||||
var FORM_TYPES = [
|
||||
{t:'PURCHASE', icon:'cart', dirTxt:'افزایش', cls:'in'},
|
||||
{t:'WITHDRAWAL', icon:'minusCirc', dirTxt:'کاهش', cls:'out'},
|
||||
{t:'RETURN', icon:'undo', dirTxt:'افزایش', cls:'in'},
|
||||
{t:'TRANSFER', icon:'out', dirTxt:'جابهجایی', cls:'mv'},
|
||||
{t:'DAMAGE', icon:'warn', dirTxt:'کاهش', cls:'out'},
|
||||
{t:'WASTE', icon:'trash', dirTxt:'کاهش', cls:'out'},
|
||||
{t:'LOSS', icon:'pkgX', dirTxt:'کاهش', cls:'out'},
|
||||
{t:'FOUND', icon:'search', dirTxt:'افزایش', cls:'in'},
|
||||
{t:'STOCK_COUNT', icon:'check', dirTxt:'اصلاح', cls:'cnt'}
|
||||
];
|
||||
var FORM_CLS_COLOR = {in:'#16a34a', out:'#1d4ed8', mv:'#4f46e5', cnt:'#64748b'};
|
||||
|
||||
var ICON_PATHS = {
|
||||
cart: ['M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12'],
|
||||
minusCirc: ['M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Z','M8 12h8'],
|
||||
undo: ['M3 7v6h6','M21 17a9 9 0 0 0-15-6.7L3 13'],
|
||||
in: ['M12 3v14','m6 9 6 6 6-6','M5 21h14'],
|
||||
out: ['M12 21V7','m6 13 6-6 6 6','M5 3h14'],
|
||||
warn: ['m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z','M12 9v4','M12 17h.01'],
|
||||
trash: ['M3 6h18','M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6','M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'],
|
||||
pkgX: ['M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z','M3.3 7 12 12l8.7-5','M12 22V12','M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4','m10 17 5-5-5-5','M15 12H3'],
|
||||
search: ['m21 21-4.34-4.34','M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z'],
|
||||
check: ['M20 6 9 17l-5-5'],
|
||||
box: ['M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z','M3.3 7 12 12l8.7-5','M12 22V12'],
|
||||
warehouse: ['M22 8.35V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8.35A2 2 0 0 1 3.26 6.5l8-3.2a2 2 0 0 1 1.48 0l8 3.2A2 2 0 0 1 22 8.35Z','M6 18h12','M6 14h12','M6 10h12'],
|
||||
plus: ['M12 5v14','M5 12h14'],
|
||||
pencil: ['M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z'],
|
||||
x: ['M18 6 6 18','m6 6 12 12'],
|
||||
chev: ['m9 18 6-6-6-6'],
|
||||
user: ['M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2','M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z'],
|
||||
note: ['M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z','M14 2v6h6','M16 13H8','M16 17H8','M10 9H8'],
|
||||
lock: ['M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z','M12 8v4','M12 16h.01'],
|
||||
sort: ['M11 5h10','M11 9h7','M11 13h4','m3 17 3 3 3-3','M6 18V4'],
|
||||
tag: ['M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z','M7 7h.01'],
|
||||
clock: ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z','M12 6v6l4 2'],
|
||||
building: ['M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z','M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2','M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2','M10 6h4','M10 10h4','M10 14h4','M10 18h4'],
|
||||
info: ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z','M12 16v-4','M12 8h.01'],
|
||||
history: ['M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8','M3 3v5h5','M12 7v5l4 2'],
|
||||
calendar: ['M8 2v4','M16 2v4','M3 10h18','M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z'],
|
||||
logout: ['M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4','m16 17 5-5-5-5','M21 12H9']
|
||||
};
|
||||
|
||||
function icon(name, cls){
|
||||
var paths=(ICON_PATHS[name]||ICON_PATHS.box).map(function(d){
|
||||
return '<path d="'+d+'"/>';
|
||||
}).join('');
|
||||
return '<svg class="'+cls+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+paths+'</svg>';
|
||||
}
|
||||
function datalist(id,vals){
|
||||
return '<datalist id="'+id+'">'+vals.map(function(v){return '<option value="'+esc(v)+'">';}).join('')+'</datalist>';
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ابزار
|
||||
============================================================ */
|
||||
|
||||
function esc(s){
|
||||
return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
function fa(n){ return Number(n).toLocaleString('fa-IR',{maximumFractionDigits:4}); }
|
||||
function faInt(n){ return String(n).replace(/\d/g,function(d){return '۰۱۲۳۴۵۶۷۸۹'[d];}); }
|
||||
function toEnNum(s){
|
||||
if(typeof s!=='string') s=String(s);
|
||||
return s.replace(/[۰-۹]/g,function(d){return '۰۱۲۳۴۵۶۷۸۹'.indexOf(d);})
|
||||
.replace(/[٠-٩]/g,function(d){return '٠١٢٣٤٥٦٧٨٩'.indexOf(d);})
|
||||
.replace(/[،,]/g,'');
|
||||
}
|
||||
function parseNum(s){
|
||||
var v=Number(toEnNum(s));
|
||||
return isFinite(v)?v:NaN;
|
||||
}
|
||||
function fmtDate(iso){
|
||||
try{return new Intl.DateTimeFormat('fa-IR',{year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date(iso));}
|
||||
catch(e){return iso;}
|
||||
}
|
||||
function fmtDateLong(iso){
|
||||
try{return new Intl.DateTimeFormat('fa-IR',{dateStyle:'full'}).format(new Date(iso));}
|
||||
catch(e){return fmtDate(iso);}
|
||||
}
|
||||
function fmtTime(iso){
|
||||
try{return new Intl.DateTimeFormat('fa-IR',{timeStyle:'short'}).format(new Date(iso));}
|
||||
catch(e){return '';}
|
||||
}
|
||||
function fmtDateTime(iso){
|
||||
try{return new Intl.DateTimeFormat('fa-IR',{dateStyle:'medium',timeStyle:'short'}).format(new Date(iso));}
|
||||
catch(e){return iso;}
|
||||
}
|
||||
function toLocalInput(d){
|
||||
var x=new Date(d); x.setMinutes(x.getMinutes()-x.getTimezoneOffset());
|
||||
return x.toISOString().slice(0,16);
|
||||
}
|
||||
function gregKey(d){
|
||||
var y=d.getFullYear(),m=d.getMonth()+1,dd=d.getDate();
|
||||
return y+'-'+(m<10?'0':'')+m+'-'+(dd<10?'0':'')+dd;
|
||||
}
|
||||
function todayKey(){ return gregKey(new Date()); }
|
||||
function specsHash(specs){
|
||||
var keys=Object.keys(specs||{}).filter(function(k){return specs[k]!==''&&specs[k]!=null;}).sort();
|
||||
var s=keys.map(function(k){return k+'='+specs[k];}).join('|');
|
||||
if(!s) return '';
|
||||
var h=5381,i;
|
||||
for(i=0;i<s.length;i++){ h=((h<<5)+h+s.charCodeAt(i))>>>0; }
|
||||
return 'h'+h.toString(16);
|
||||
}
|
||||
function specsText(specs){
|
||||
var keys=Object.keys(specs||{}).filter(function(k){return specs[k]!==''&&specs[k]!=null;});
|
||||
if(!keys.length) return '—';
|
||||
return keys.map(function(k){return k+' : '+specs[k];}).join(' · ');
|
||||
}
|
||||
function specsChips(specs){
|
||||
var keys=Object.keys(specs||{}).filter(function(k){return specs[k]!==''&&specs[k]!=null;});
|
||||
if(!keys.length) return '';
|
||||
return keys.map(function(k){return '<span class="spec-chip">'+esc(k)+' : '+esc(specs[k])+'</span>';}).join('');
|
||||
}
|
||||
function hexBg(hex,alpha){
|
||||
var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
|
||||
return 'rgba('+r+','+g+','+b+','+alpha+')';
|
||||
}
|
||||
function keyOf(wh,m,b,sh){ return wh+'|'+m+'|'+(b||'')+'|'+sh; }
|
||||
function recById(id){
|
||||
if(!id) return null;
|
||||
for(var i=0;i<state.inventory.length;i++){
|
||||
if(state.inventory[i].id===id) return state.inventory[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
فهرستها (آینه دادههای API)
|
||||
============================================================ */
|
||||
|
||||
var state={ projects:[], warehouses:[], inventory:[], materials:[], brands:[], units:[], categories:[], transactions:[] };
|
||||
|
||||
function catName(id){
|
||||
var c=state.categories.find(function(x){return x.id===id;});
|
||||
return c?(CATEGORY_FA[c.slug]||c.name):'—';
|
||||
}
|
||||
function unitName(id){
|
||||
var u=state.units.find(function(x){return String(x.id)===String(id);});
|
||||
return u?(u.name_fa||u.code):'—';
|
||||
}
|
||||
function matName(id){
|
||||
var m=state.materials.find(function(x){return x.id===id;});
|
||||
return m?m.name:'—';
|
||||
}
|
||||
function matByName(txt){
|
||||
txt=(txt||'').trim();
|
||||
return state.materials.find(function(x){return x.name===txt;})||null;
|
||||
}
|
||||
function brandName(id){
|
||||
var b=state.brands.find(function(x){return String(x.id)===String(id);});
|
||||
return b?b.name:'—';
|
||||
}
|
||||
function brandIdByName(txt){
|
||||
txt=(txt||'').trim();
|
||||
var b=state.brands.find(function(x){return x.name===txt;});
|
||||
return b?b.id:null;
|
||||
}
|
||||
function whName(id){
|
||||
var w=state.warehouses.find(function(x){return String(x.id)===String(id);});
|
||||
return w?w.name:'—';
|
||||
}
|
||||
function whById(id){
|
||||
return state.warehouses.find(function(x){return String(x.id)===String(id);})||null;
|
||||
}
|
||||
function projName(id){
|
||||
var p=state.projects.find(function(x){return String(x.id)===String(id);});
|
||||
return p?p.name:'—';
|
||||
}
|
||||
function txMeta(type){ return TX[type]||{label:type,icon:'box',color:'#64748b',dir:'',grp:''}; }
|
||||
|
||||
/* ============================================================
|
||||
session / user (هویت محلی — نقش و پروژه)
|
||||
============================================================ */
|
||||
|
||||
var SESSION_KEY='inventory-session-v1';
|
||||
function loadSession(){
|
||||
try{
|
||||
var raw=localStorage.getItem(SESSION_KEY);
|
||||
if(!raw) return null;
|
||||
var o=JSON.parse(raw);
|
||||
if(o.mode==='casdoor'){
|
||||
if(o.email&&state.users.some(function(u){return u.email&&String(u.email).trim().toLowerCase()===String(o.email).trim().toLowerCase();})) return o;
|
||||
return null;
|
||||
}
|
||||
if(state.users.some(function(u){return u.pw===o.pw;})) return o;
|
||||
return null;
|
||||
}catch(e){ return null; }
|
||||
}
|
||||
function saveSession(){ try{ localStorage.setItem(SESSION_KEY, JSON.stringify(session)); }catch(e){} }
|
||||
function user(){
|
||||
if(!session) return null;
|
||||
if(session.mode==='casdoor'){
|
||||
var em=String(session.email||'').trim().toLowerCase();
|
||||
return state.users.find(function(u){return u.email&&String(u.email).trim().toLowerCase()===em;})||null;
|
||||
}
|
||||
return state.users.find(function(u){return u.pw===session.pw;})||null;
|
||||
}
|
||||
function isAdmin(){ return user()&&user().role==='admin'; }
|
||||
function isKeeper(){ return user()&&user().role==='keeper'; }
|
||||
|
||||
var SEED_USERS = [
|
||||
{pw:'10', role:'admin', name:'آقای رضایی', label:'ادمین سیستم (فقط خواندنی)', projectId:'', projectName:'', email:'razaei@sample.ir'},
|
||||
{pw:'1', role:'keeper', name:'آقای موسوی', label:'مدیر پروژه', projectId:'برج آیتا — فاز ۳', projectName:'برج آیتا — فاز ۳', email:'mousavi@sample.ir'},
|
||||
{pw:'2', role:'keeper', name:'آقای گلی', label:'مدیر پروژه', projectId:'برج آیتا — فاز ۳', projectName:'برج آیتا — فاز ۳', email:'goli@sample.ir'},
|
||||
{pw:'3', role:'keeper', name:'آقای محقق', label:'مدیر پروژه', projectId:'مجتمع تجاری الماس', projectName:'مجتمع تجاری الماس', email:'mohaghegh@sample.ir'}
|
||||
];
|
||||
|
||||
function loadUsers(){
|
||||
try{
|
||||
var raw=localStorage.getItem(DB_KEY);
|
||||
if(!raw){ state.users=SEED_USERS.map(function(u){return JSON.parse(JSON.stringify(u));}); saveUsers(); return; }
|
||||
var s=JSON.parse(raw);
|
||||
if(s&&s.users){ state.users=s.users; }
|
||||
else{ state.users=SEED_USERS.map(function(u){return JSON.parse(JSON.stringify(u));}); saveUsers(); }
|
||||
/* مهاجرت: ایمیل + نام پروژه + بازیابی projectId کهنه (بعد از re-seed دیتابیس) */
|
||||
SEED_USERS.forEach(function(su){
|
||||
var u=state.users.find(function(x){return x.pw===su.pw;});
|
||||
if(!u) return;
|
||||
if(!u.email) u.email=su.email;
|
||||
if(!u.projectName) u.projectName=su.projectName||'';
|
||||
var pid=String(u.projectId||'');
|
||||
var isUuid=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(pid);
|
||||
/* شناسه UUID که به هیچ پروژه فعلی نمیخورد (کهنه) → برگرد به نام پروژه برای بازحلول */
|
||||
if(isUuid&&su.projectName&&pid!==su.projectName){
|
||||
var knownNow=false;
|
||||
state.projects.forEach(function(p){ if(String(p.id)===pid) knownNow=true; });
|
||||
if(!knownNow) u.projectId=su.projectName;
|
||||
}
|
||||
});
|
||||
}catch(e){ state.users=SEED_USERS.map(function(u){return JSON.parse(JSON.stringify(u));}); }
|
||||
}
|
||||
function saveUsers(){ try{ localStorage.setItem(DB_KEY, JSON.stringify({users:state.users})); }catch(e){} }
|
||||
var DB_KEY='inventory-users-v1';
|
||||
|
||||
/* ============================================================
|
||||
لایه API — هدرهای نقش/پروژه و فراخوانیها
|
||||
============================================================ */
|
||||
|
||||
function authHeaders(){
|
||||
var h={'Content-Type':'application/json'};
|
||||
var u=user();
|
||||
if(!u) return h;
|
||||
h['X-User-Role']=(u.role==='admin')?'admin':'pm';
|
||||
/* X-Project-Id فقط وقتی شناسه معتبر (UUID) است — قبل از resolve نام پروژه است */
|
||||
if(u.role==='keeper'&&u.projectId&&/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(u.projectId))){
|
||||
h['X-Project-Id']=u.projectId;
|
||||
}
|
||||
/* هدرها باید ASCII باشند — نام فارسی با base64 فرستاده میشود */
|
||||
try{ h['X-User-Name-B64']=btoa(unescape(encodeURIComponent(u.name))); }catch(e){}
|
||||
return h;
|
||||
}
|
||||
|
||||
function apiFetch(method, path, body){
|
||||
var opts={method:method, headers:authHeaders()};
|
||||
if(body!==undefined){ opts.body=JSON.stringify(body); }
|
||||
return fetch(API_BASE+path, opts).then(function(res){
|
||||
return res.json().catch(function(){ return {}; }).then(function(j){
|
||||
if(!res.ok||j.error){
|
||||
throw new Error((j.error&&j.error.message)||('خطای سرور ('+res.status+')'));
|
||||
}
|
||||
return j.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
function apiGet(path, params){
|
||||
var qs='';
|
||||
if(params){
|
||||
var parts=[];
|
||||
Object.keys(params).forEach(function(k){
|
||||
if(params[k]!==undefined&¶ms[k]!==null&¶ms[k]!=='') parts.push(encodeURIComponent(k)+'='+encodeURIComponent(params[k]));
|
||||
});
|
||||
if(parts.length) qs='?'+parts.join('&');
|
||||
}
|
||||
return apiFetch('GET', path+qs);
|
||||
}
|
||||
function apiPost(path, body){ return apiFetch('POST', path, body||{}); }
|
||||
function apiPatch(path, body){ return apiFetch('PATCH', path, body||{}); }
|
||||
function apiDelete(path){ return apiFetch('DELETE', path); }
|
||||
|
||||
/* بارگذاری همه دادهها از API (طبق نقش کاربر) */
|
||||
function loadAll(){
|
||||
return apiGet('/projects',{page_size:100}).then(function(projs){
|
||||
state.projects=projs||[];
|
||||
/* پروژه کاربران محلی را به شناسه واقعی (UUID) تبدیل کن — قبل از فراخوانیهای بعدی */
|
||||
resolveUserProjectIds();
|
||||
return Promise.all([
|
||||
apiGet('/material-categories',{page_size:100}),
|
||||
apiGet('/units',{page_size:100}),
|
||||
apiGet('/brands',{page_size:100}),
|
||||
apiGet('/materials',{page_size:100, expand:'category,brands,canonical_unit'}),
|
||||
apiGet('/inventory/warehouses',{page_size:100}),
|
||||
apiGet('/inventory',{page_size:100}),
|
||||
apiGet('/inventory/transactions',{page_size:100})
|
||||
]).then(function(r){
|
||||
state.categories=r[0]||[]; state.units=r[1]||[]; state.brands=r[2]||[];
|
||||
state.materials=r[3]||[]; state.warehouses=r[4]||[];
|
||||
state.inventory=r[5]||[]; state.transactions=r[6]||[];
|
||||
return true;
|
||||
});
|
||||
}).catch(function(e){
|
||||
toast('خطا در دریافت دادهها: '+e.message,'err');
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/* کاربران محلی که projectId نام پروژه دارند → تبدیل به شناسه UUID واقعی */
|
||||
function resolveUserProjectIds(){
|
||||
var changed=false;
|
||||
state.users.forEach(function(u){
|
||||
if(u.role!=='keeper'||!u.projectId) return;
|
||||
if(state.projects.some(function(p){return String(p.id)===String(u.projectId);})) return;
|
||||
/* fallback ۱: نام پروژه ذخیرهشده (projectName) */
|
||||
var p=u.projectName?state.projects.find(function(x){return x.name===u.projectName;}):null;
|
||||
/* fallback ۲: خود projectId نام پروژه باشد */
|
||||
if(!p) p=state.projects.find(function(x){return x.name===u.projectId;});
|
||||
if(p){ u.projectId=p.id; u.projectName=p.name; changed=true; }
|
||||
else if(u.projectId){ u.projectId=''; changed=true; }
|
||||
});
|
||||
if(changed) saveUsers();
|
||||
}
|
||||
|
||||
/* پروژه واقعی کاربر جاری (برای هدر X-Project-Id) */
|
||||
function userProjectId(){
|
||||
var u=user();
|
||||
if(!u||u.role!=='keeper') return null;
|
||||
return u.projectId||null;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
توست
|
||||
============================================================ */
|
||||
|
||||
function toast(msg,kind){
|
||||
kind=kind||'info';
|
||||
var el=document.createElement('div');
|
||||
el.className='toast '+kind;
|
||||
var ic=kind==='ok'?'check':(kind==='err'?'x':'info');
|
||||
el.innerHTML=icon(ic,'')+'<span>'+esc(msg)+'</span>';
|
||||
document.getElementById('toasts').appendChild(el);
|
||||
setTimeout(function(){ el.classList.add('hide'); setTimeout(function(){el.remove();},350); },4000);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
login / logout
|
||||
============================================================ */
|
||||
|
||||
function doLogin(){
|
||||
var pw=document.getElementById('login-pass').value.trim();
|
||||
var u=state.users.find(function(x){return x.pw===pw;});
|
||||
if(!u){
|
||||
document.getElementById('login-err').textContent='رمز عبور اشتباه است.';
|
||||
return;
|
||||
}
|
||||
session={pw:pw};
|
||||
saveSession();
|
||||
ui.form=emptyForm('PURCHASE');
|
||||
document.getElementById('login').classList.add('hidden');
|
||||
document.getElementById('app').classList.remove('hidden');
|
||||
toast('خوش آمدید، '+u.name+' ✓','ok');
|
||||
loadAll().then(function(ok){ renderChrome(); renderPage(); });
|
||||
}
|
||||
function logout(){
|
||||
session=null;
|
||||
try{ localStorage.removeItem(SESSION_KEY); }catch(e){}
|
||||
document.getElementById('app').classList.add('hidden');
|
||||
document.getElementById('login').classList.remove('hidden');
|
||||
document.getElementById('login-pass').value='';
|
||||
document.getElementById('login-err').textContent='';
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ورود با حساب سازمانی (Casdoor — OIDC Authorization Code + PKCE)
|
||||
============================================================ */
|
||||
|
||||
function casdoorConfig(){ return (typeof window!=='undefined'&&window.CASDOOR_CONFIG)||null; }
|
||||
|
||||
function b64urlFromBytes(bytes){
|
||||
var s='';
|
||||
for(var i=0;i<bytes.length;i++) s+=String.fromCharCode(bytes[i]);
|
||||
return btoa(s).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||
}
|
||||
function randomB64(n){
|
||||
var bytes=new Uint8Array(n);
|
||||
var g=null;
|
||||
try{ if(crypto&&crypto.getRandomValues) g=crypto.getRandomValues.bind(crypto); }catch(e){}
|
||||
if(g) g(bytes);
|
||||
else for(var i=0;i<n;i++) bytes[i]=Math.floor(Math.random()*256);
|
||||
return b64urlFromBytes(bytes);
|
||||
}
|
||||
function sha256B64url(s){
|
||||
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(s))
|
||||
.then(function(buf){ return b64urlFromBytes(new Uint8Array(buf)); });
|
||||
}
|
||||
|
||||
/* ساخت آدرس ورود Casdoor با PKCE و هدایت کاربر */
|
||||
function startCasdoorLogin(){
|
||||
var c=casdoorConfig();
|
||||
if(!c){ toast('تنظیمات Casdoor یافت نشد — فایل casdoor-config.js را بررسی کنید','err'); return; }
|
||||
var verifier=randomB64(64);
|
||||
var state=randomB64(16);
|
||||
sha256B64url(verifier).then(function(challenge){
|
||||
try{
|
||||
sessionStorage.setItem('casdoor-pkce', JSON.stringify({verifier:verifier,state:state}));
|
||||
}catch(e){}
|
||||
var params=[
|
||||
'client_id='+encodeURIComponent(c.clientId),
|
||||
'redirect_uri='+encodeURIComponent(c.redirectUri),
|
||||
'response_type=code',
|
||||
'scope=read',
|
||||
'state='+encodeURIComponent(state),
|
||||
'organization='+encodeURIComponent(c.organization),
|
||||
'application='+encodeURIComponent(c.appName),
|
||||
'code_challenge='+encodeURIComponent(challenge),
|
||||
'code_challenge_method=S256'
|
||||
];
|
||||
var url=c.serverUrl+'/login/oauth/authorize?'+params.join('&');
|
||||
if(typeof location!=='undefined') location.href=url;
|
||||
}).catch(function(){ toast('خطا در ساخت درخواست ورود','err'); });
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
navigation + session bar (مشترک)
|
||||
============================================================ */
|
||||
|
||||
function viewName(v){
|
||||
return v==='dashboard'?'داشبورد انبار':(v==='change'?'تغییر موجودی':(v==='calendar'?'کنترل روزانه':(v==='users'?'سازماندهی':'انبارها')));
|
||||
}
|
||||
|
||||
/* هر صفحه لیست ناوبری خودش را در window.NAV_ITEMS تعریف میکند */
|
||||
function renderNav(){
|
||||
var navEl=document.getElementById('nav');
|
||||
if(!navEl) return;
|
||||
var items=((typeof window!=='undefined'&&window.NAV_ITEMS)||[]).filter(function(it){
|
||||
if(it.admin===true) return isAdmin();
|
||||
if(it.admin===false) return !isAdmin();
|
||||
return true;
|
||||
});
|
||||
navEl.innerHTML=items.map(function(it){
|
||||
var cls='nav-btn'+(it.active||ui.view===it.view?' active':'');
|
||||
if(it.href) return '<a class="'+cls+'" href="'+it.href+'">'+icon(it.ic,'')+esc(it.label)+'</a>';
|
||||
return '<button class="'+cls+'" onclick="setView(\''+it.view+'\')">'+icon(it.ic,'')+esc(it.label)+'</button>';
|
||||
}).join('');
|
||||
var backBtn=document.getElementById('back-btn');
|
||||
if(backBtn){
|
||||
backBtn.style.display=ui.history.length?'inline-flex':'none';
|
||||
backBtn.title=ui.history.length?'بازگشت به '+esc(viewName(ui.history[ui.history.length-1])):'';
|
||||
}
|
||||
/* دکمه «تغییر موجودی» گوشه بالا-چپ — فقط برای مدیران پروژه (ادمین فقطخواندنی) */
|
||||
var chLink=document.getElementById('change-link');
|
||||
if(chLink) chLink.style.display=isAdmin()?'none':'inline-flex';
|
||||
}
|
||||
|
||||
function renderSession(){
|
||||
var u=user();
|
||||
if(!u){ return; }
|
||||
var html='';
|
||||
html+='<div class="user-chip">'+icon('user','')+esc(u.name)+' — '+esc(u.label)+'</div>';
|
||||
if(session&&session.mode==='casdoor'){
|
||||
html+='<span class="idp-chip" title="ورود با حساب سازمانی">'+icon('lock','')+'حساب سازمانی (Casdoor)</span>';
|
||||
}
|
||||
if(u.role==='admin'){
|
||||
html+='<div class="sb-field">'+icon('building','')+'<label>پروژه:</label>'
|
||||
+'<select onchange="onAdminProjectChange(this.value)">'
|
||||
+'<option value=""'+(ui.adminProject===''?' selected':'')+'>همه پروژهها</option>'
|
||||
+state.projects.map(function(p){return '<option value="'+p.id+'"'+(ui.adminProject===p.id?' selected':'')+'>'+esc(p.name)+'</option>';}).join('')
|
||||
+'</select></div>';
|
||||
html+='<div class="readonly-chip">'+icon('lock','')+' فقط خواندنی — امکان ثبت و ویرایش وجود ندارد</div>';
|
||||
}else{
|
||||
html+='<div class="user-chip" style="background:var(--green-bg);color:var(--green);border-color:#bbf7d0">'
|
||||
+icon('building','')+esc(projName(u.projectId))+' — مدیر پروژه</div>';
|
||||
}
|
||||
html+='<button class="logout-btn" onclick="logout()">'+icon('logout','')+'خروج</button>';
|
||||
document.getElementById('sessionbar').innerHTML=html;
|
||||
}
|
||||
|
||||
function renderChrome(){
|
||||
renderNav();
|
||||
renderSession();
|
||||
}
|
||||
function renderPage(){
|
||||
if(typeof renderAll==='function') renderAll();
|
||||
else if(typeof renderChange==='function') renderChange();
|
||||
}
|
||||
|
||||
function onAdminProjectChange(v){
|
||||
ui.adminProject=v;
|
||||
ui.filters={category:'',warehouse:'',material:''};
|
||||
ui.expandedId=null; ui.selTx=null;
|
||||
renderChrome();
|
||||
renderPage();
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
UI state
|
||||
============================================================ */
|
||||
|
||||
function emptyForm(type){
|
||||
return {
|
||||
type:type||'PURCHASE',
|
||||
whId:'',
|
||||
matTxt:'', brandTxt:'', unitTxt:'',
|
||||
specsRows:[{rid:1,label:'',value:''}], specSeq:2,
|
||||
qty:'', note:'', dateInput:toLocalInput(new Date()),
|
||||
selMatId:'', matTxt:'',
|
||||
rows:[],
|
||||
fromId:'', toId:''
|
||||
};
|
||||
}
|
||||
|
||||
var ui={
|
||||
view:'dashboard',
|
||||
adminProject:'',
|
||||
filters:{category:'',warehouse:'',material:''},
|
||||
sort:'',
|
||||
expandedId:null, selTx:null,
|
||||
form:emptyForm('PURCHASE'),
|
||||
cal:{anchor:new Date(), sel:todayKey(), actRec:null},
|
||||
history:[],
|
||||
whEditId:null, whEditName:'', whEditDesc:'',
|
||||
whNewName:'', whNewDesc:'', whNewProj:'',
|
||||
newUserName:'', newUserPw:'', newUserProj:'', newUserEmail:'',
|
||||
pmEditId:null, pmEditName:'', pmEditPw:'', pmEditProj:'', pmEditEmail:''
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
scope (انبارها/پروژههای قابل مشاهده)
|
||||
============================================================ */
|
||||
|
||||
function scopedWarehouses(){
|
||||
var u=user();
|
||||
if(!u) return [];
|
||||
if(u.role==='admin'){
|
||||
var pid=ui.adminProject;
|
||||
return pid?state.warehouses.filter(function(w){return w.project_id===pid;}):state.warehouses.slice();
|
||||
}
|
||||
return state.warehouses.filter(function(w){return String(w.project_id)===String(u.projectId);});
|
||||
}
|
||||
function scopedProjects(){
|
||||
var u=user();
|
||||
if(!u) return [];
|
||||
if(u.role==='admin'){
|
||||
return ui.adminProject?[ui.adminProject]:state.projects.map(function(p){return p.id;});
|
||||
}
|
||||
return u.projectId?[u.projectId]:[];
|
||||
}
|
||||
@@ -1,909 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>سامانه مدیریت انبار</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=3">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ==================== LOGIN ==================== -->
|
||||
<div id="login">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/></svg>
|
||||
</div>
|
||||
<h1>سامانه مدیریت انبار</h1>
|
||||
<div class="sub">برای ورود، رمز عبور خود را وارد کنید</div>
|
||||
<label>رمز عبور</label>
|
||||
<input type="password" id="login-pass" autocomplete="off" placeholder="••••" onkeydown="if(event.key==='Enter')doLogin()">
|
||||
<button class="login-btn" onclick="doLogin()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="m10 17 5-5-5-5"/><path d="M15 12H3"/></svg>
|
||||
ورود به سامانه
|
||||
</button>
|
||||
<div class="login-divider">یا</div>
|
||||
<button class="login-btn casdoor-btn" onclick="startCasdoorLogin()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="m10 17 5-5-5-5"/><path d="M15 12H3"/></svg>
|
||||
ورود با حساب سازمانی
|
||||
</button>
|
||||
<div class="login-err" id="login-err"></div>
|
||||
<div class="demo-hint">
|
||||
<b>رمزهای آزمایشی:</b><br>
|
||||
۱ ← مدیر پروژه برج آیتا — فاز ۳ (آقای موسوی) | ۲ ← مدیر پروژه برج آیتا — فاز ۳ (آقای گلی)<br>
|
||||
۳ ← مدیر پروژه مجتمع تجاری الماس (آقای محقق) | ۱۰ ← ادمین (آقای رضایی — فقط خواندنی)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== APP ==================== -->
|
||||
<div id="app" class="hidden">
|
||||
<div class="topbar">
|
||||
<div class="brand">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1>سامانه مدیریت انبار</h1>
|
||||
<div class="sub">دادهها در پایگاه داده ذخیره میشوند</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav" id="nav"></nav>
|
||||
<div class="top-actions">
|
||||
<button class="ghost-btn" id="back-btn" onclick="goBack()" style="display:none">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
||||
بازگشت
|
||||
</button>
|
||||
<a class="change-link" id="change-link" href="change-inventory.html" title="تغییر موجودی">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||
تغییر موجودی
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="sessionbar" id="sessionbar"></section>
|
||||
|
||||
<main id="main"></main>
|
||||
</div>
|
||||
|
||||
<div id="toasts"></div>
|
||||
|
||||
<script src="casdoor-config.js?v=3"></script>
|
||||
<script src="app-config.js?v=3"></script>
|
||||
<script src="core.js?v=3"></script>
|
||||
<script>
|
||||
/* ============================================================
|
||||
|
||||
inventory-system.html — داشبورد، کنترل روزانه، انبارها، سازماندهی
|
||||
(متصل به بکاند FastAPI — دادهها از /api/v1)
|
||||
|
||||
============================================================ */
|
||||
|
||||
window.NAV_ITEMS=[
|
||||
{id:'dashboard', label:'داشبورد انبار', ic:'box', view:'dashboard'},
|
||||
{id:'calendar', label:'کنترل روزانه', ic:'calendar', view:'calendar'},
|
||||
{id:'warehouses', label:'انبارها', ic:'warehouse', view:'warehouses'},
|
||||
{id:'users', label:'سازماندهی', ic:'user', view:'users', admin:true}
|
||||
];
|
||||
|
||||
function setView(v){
|
||||
if(v===ui.view) return;
|
||||
ui.history.push(ui.view);
|
||||
ui.view=v;
|
||||
if(v!=='change'){ ui.form=emptyForm(ui.form.type); }
|
||||
ui.expandedId=null; ui.selTx=null; ui.expBrand=null; ui.cal.actRec=null;
|
||||
renderAll();
|
||||
}
|
||||
function goBack(){
|
||||
if(!ui.history.length) return;
|
||||
var p=ui.history.pop();
|
||||
if(p==='change'){ ui.form=emptyForm(ui.form.type); }
|
||||
ui.view=p;
|
||||
ui.expandedId=null; ui.selTx=null; ui.expBrand=null; ui.cal.actRec=null;
|
||||
renderAll();
|
||||
}
|
||||
|
||||
/* پرش از «کنترل روزانه» به صفحه تغییر موجودی (فایل جدا) با پیشپر کردن از طریق URL */
|
||||
function quickChange(type,whId,matTxt){
|
||||
if(isKeeper()){
|
||||
var pw=whById(whId);
|
||||
if(!pw||String(pw.project_id)!==String(user().projectId)){
|
||||
toast('برگشت / مفقودی / اتلاف فقط برای انبارهای پروژه خودتان ممکن است','err');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if(typeof location!=='undefined'){
|
||||
location.href='change-inventory.html?type='+encodeURIComponent(type)
|
||||
+'&wh='+encodeURIComponent(whId)
|
||||
+'&mat='+encodeURIComponent(matTxt||'');
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
dashboard
|
||||
============================================================ */
|
||||
|
||||
function matCategoryName(matId){
|
||||
var m=state.materials.find(function(x){return String(x.id)===String(matId);});
|
||||
return m&&m.category?catName(m.category.id):'—';
|
||||
}
|
||||
|
||||
/* ردیفهای داشبورد: گروهبندی بر اساس انبار + ماده اولیه + مشخصه فنی — برندها داخل گروه ادغام میشوند */
|
||||
function visibleGroups(){
|
||||
var whs=scopedWarehouses();
|
||||
var whIds={}; whs.forEach(function(w){whIds[w.id]=1;});
|
||||
var f=ui.filters;
|
||||
var groups={},order=[];
|
||||
state.inventory.forEach(function(r){
|
||||
var whId=r.warehouse&&r.warehouse.id;
|
||||
if(!whIds[whId]) return;
|
||||
var matId=r.material&&r.material.id;
|
||||
if(f.category&&(!r.material||!r.material.category||r.material.category.id!==f.category)) return;
|
||||
if(f.warehouse&&whId!==f.warehouse) return;
|
||||
if(f.material&&matName(matId).indexOf(f.material.trim())<0) return;
|
||||
var gk=whId+'|'+matId+'|'+(specsHash(r.specs)||'default');
|
||||
var g=groups[gk];
|
||||
if(!g){
|
||||
g={key:gk, whId:whId, matId:matId, specs:r.specs||{}, unitId:r.unit&&r.unit.id,
|
||||
qty:0, updatedAt:r.updated_at, recs:[], brands:{}, brandOrder:[]};
|
||||
groups[gk]=g; order.push(gk);
|
||||
}
|
||||
g.qty+=Number(r.quantity_number)||0;
|
||||
if(r.updated_at>g.updatedAt) g.updatedAt=r.updated_at;
|
||||
g.recs.push(r);
|
||||
var bid=r.brand&&r.brand.id;
|
||||
if(bid){
|
||||
if(!g.brands[bid]){ g.brands[bid]=0; g.brandOrder.push(bid); }
|
||||
g.brands[bid]+=Number(r.quantity_number)||0;
|
||||
}
|
||||
});
|
||||
var out=order.map(function(gk){return groups[gk];});
|
||||
if(ui.sort==='asc'){ out.sort(function(a,b){return a.qty-b.qty;}); }
|
||||
else if(ui.sort==='desc'){ out.sort(function(a,b){return b.qty-a.qty;}); }
|
||||
else{ out.sort(function(a,b){return new Date(b.updatedAt)-new Date(a.updatedAt);}); }
|
||||
return out;
|
||||
}
|
||||
|
||||
function groupByKey(gk){
|
||||
return visibleGroups().find(function(g){return g.key===gk;})||null;
|
||||
}
|
||||
|
||||
function filterMaterialOptions(){
|
||||
var whs=scopedWarehouses();
|
||||
var whIds={}; whs.forEach(function(w){whIds[w.id]=1;});
|
||||
var set={};
|
||||
state.inventory.forEach(function(r){
|
||||
var whId=r.warehouse&&r.warehouse.id;
|
||||
if(!whIds[whId]) return;
|
||||
if(ui.filters.category&&(!r.material||!r.material.category||r.material.category.id!==ui.filters.category)) return;
|
||||
if(ui.filters.warehouse&&whId!==ui.filters.warehouse) return;
|
||||
set[r.material&&r.material.id]=1;
|
||||
});
|
||||
return Object.keys(set).map(function(id){return matName(id);})
|
||||
.sort(function(a,b){return a.localeCompare(b,'fa');});
|
||||
}
|
||||
|
||||
function renderDashboard(){
|
||||
var whs=scopedWarehouses();
|
||||
var rows=visibleGroups();
|
||||
|
||||
var catSel='<select onchange="onFilterCategory(this.value)">'
|
||||
+'<option value="">همه دستهبندیها</option>'
|
||||
+state.categories.map(function(c){
|
||||
return '<option value="'+c.id+'"'+(ui.filters.category===c.id?' selected':'')+'>'+esc(catName(c.id))+'</option>';
|
||||
}).join('')
|
||||
+'</select>';
|
||||
|
||||
/* فیلتر پروژه برای ادمین: انتخاب پروژه، انبارهای همان پروژه را در فیلتر انبار میآورد */
|
||||
var projSel='';
|
||||
if(isAdmin()){
|
||||
projSel='<div class="fb-field"><label>پروژه</label><select onchange="onAdminProjectChange(this.value)">'
|
||||
+'<option value=""'+(ui.adminProject===''?' selected':'')+'>همه پروژهها</option>'
|
||||
+state.projects.map(function(p){return '<option value="'+p.id+'"'+(ui.adminProject===p.id?' selected':'')+'>'+esc(p.name)+'</option>';}).join('')
|
||||
+'</select></div>';
|
||||
}
|
||||
|
||||
/* فیلتر انبار برای همه: ادمین با توجه به پروژه انتخابی، مدیر پروژه با انبارهای پروژه خودش */
|
||||
var whSel='<div class="fb-field"><label>انبار</label><select onchange="onFilterWarehouse(this.value)">'
|
||||
+'<option value="">همه انبارها</option>'
|
||||
+whs.map(function(w){
|
||||
return '<option value="'+w.id+'"'+(ui.filters.warehouse===w.id?' selected':'')+'>'+esc(w.name)
|
||||
+(isAdmin()?' — '+esc(projName(w.project_id)):'')+'</option>';
|
||||
}).join('')
|
||||
+'</select></div>';
|
||||
|
||||
var matNames=filterMaterialOptions();
|
||||
var matInp=datalist('filt-mat',matNames)
|
||||
+'<input type="text" id="filt-mat-input" list="filt-mat" placeholder="تایپ نام ماده اولیه…" value="'+esc(ui.filters.material)+'" oninput="onFilterMaterial(this.value)">';
|
||||
|
||||
var sortLabel=ui.sort==='asc'?'تعداد: صعودی ▲':(ui.sort==='desc'?'تعداد: نزولی ▼':'مرتبسازی تعداد');
|
||||
var hasFilter=ui.filters.category||ui.filters.warehouse||ui.filters.material;
|
||||
|
||||
var head=rowHeadHTML();
|
||||
var body='';
|
||||
if(!rows.length){
|
||||
body='<tr><td colspan="9"><div class="empty-box">هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد.</div></td></tr>';
|
||||
}else{
|
||||
rows.forEach(function(r){ body+=rowHTML(r); });
|
||||
}
|
||||
|
||||
document.getElementById('main').innerHTML=
|
||||
'<div class="filterbar">'
|
||||
+'<div class="fb-field"><label>دستهبندی</label>'+catSel+'</div>'
|
||||
+projSel
|
||||
+whSel
|
||||
+'<div class="fb-field"><label>ماده اولیه</label>'+matInp+'</div>'
|
||||
+'<button class="sort-btn'+(ui.sort?' active':'')+'" onclick="toggleSort()">'+icon('sort','')+sortLabel+'</button>'
|
||||
+(hasFilter?'<button class="clear-btn" onclick="clearFilters()">'+icon('x','')+'پاک کردن فیلترها</button>':'')
|
||||
+'<div class="count-chip">'+faInt(rows.length)+' قلم کالا</div>'
|
||||
+'</div>'
|
||||
+'<div class="card"><div class="tbl-wrap"><table class="inv">'+head
|
||||
+'<tbody>'+body+'</tbody></table></div></div>'
|
||||
+'<div class="footer-note">۱۰ نوع تراکنش استاندارد • افزایش موجودی سبز، کاهش آبی • کلیک روی هر سطر: خط زمانی با «مانده» لحظهبهلحظه و تفکیک برندها</div>';
|
||||
}
|
||||
|
||||
function rowHeadHTML(){
|
||||
return '<thead><tr>'
|
||||
+'<th style="width:26px"></th>'
|
||||
+'<th>ماده اولیه</th><th>دستهبندی</th><th>انبار</th><th>مشخصه فنی</th><th>تعداد</th><th>واحد</th>'
|
||||
+'<th>آخرین بهروزرسانی</th><th style="width:30px"></th>'
|
||||
+'</tr></thead>';
|
||||
}
|
||||
|
||||
function rowHTML(g){
|
||||
var w=whById(g.whId);
|
||||
var isExp=ui.expandedId===g.key;
|
||||
var specs=specsChips(g.specs);
|
||||
var html='<tr class="data-row'+(isExp?' expanded':'')+'" onclick="toggleRow(\''+g.key+'\')">'
|
||||
+'<td>'+icon('chev','caret')+'</td>'
|
||||
+'<td class="mat-cell">'+esc(matName(g.matId))+'</td>'
|
||||
+'<td>'+esc(matCategoryName(g.matId))+'</td>'
|
||||
+'<td class="wh-cell">'+esc(w?w.name:'—')
|
||||
+(isAdmin()?'<small>'+esc(projName(w?w.project_id:''))+'</small>':'')+'</td>'
|
||||
+'<td>'+(specs||'<span style="color:var(--muted)">—</span>')+'</td>'
|
||||
+'<td class="qty-cell">'+fa(g.qty)+'</td>'
|
||||
+'<td class="unit-cell">'+esc(unitName(g.unitId))+'</td>'
|
||||
+'<td class="date-cell">'+esc(fmtDateTime(g.updatedAt))+'</td>'
|
||||
+'<td>'+icon('history','row-ico')+'</td>'
|
||||
+'</tr>';
|
||||
if(isExp){
|
||||
html+='<tr class="tl-row"><td colspan="9">'+timelineHTML(g)+'</td></tr>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function toggleRow(gk){
|
||||
if(ui.expandedId===gk){ ui.expandedId=null; ui.selTx=null; ui.expBrand=null; }
|
||||
else{ ui.expandedId=gk; ui.selTx=null; ui.expBrand=null; }
|
||||
renderDashboard();
|
||||
}
|
||||
function toggleSort(){
|
||||
ui.sort=ui.sort===''?'asc':(ui.sort==='asc'?'desc':'');
|
||||
renderDashboard();
|
||||
}
|
||||
function clearFilters(){ ui.filters={category:'',warehouse:'',material:''}; renderDashboard(); }
|
||||
function onFilterCategory(v){ ui.filters.category=v; ui.filters.material=''; renderDashboard(); }
|
||||
function onFilterWarehouse(v){ ui.filters.warehouse=v; ui.filters.material=''; renderDashboard(); }
|
||||
function onFilterMaterial(v){
|
||||
ui.filters.material=v;
|
||||
renderDashboard();
|
||||
var el=document.getElementById('filt-mat-input');
|
||||
if(el){ el.focus(); var L=el.value.length; try{el.setSelectionRange(L,L);}catch(e){} }
|
||||
}
|
||||
|
||||
/* ---------- timeline (مانده لحظهبهلحظه — جریان هر برند + جریان کلی) ---------- */
|
||||
|
||||
function normTx(t){
|
||||
return {
|
||||
id:t.id, type:t.transaction_type, qty:Number(t.quantity_number)||0,
|
||||
date:t.transaction_date, recId:t.inventory_id, whId:t.warehouse_id,
|
||||
whName:t.warehouse&&t.warehouse.name, pairId:t.transfer_pair_id,
|
||||
recorded:t.recorded_qty, physical:t.physical_qty,
|
||||
performedBy:t.performed_by, note:t.note,
|
||||
material:t.material, brand:t.brand, specs:t.specs||{}, unit:t.unit
|
||||
};
|
||||
}
|
||||
|
||||
function groupTxs(g){
|
||||
var recIds={}; g.recs.forEach(function(r){recIds[r.id]=1;});
|
||||
return state.transactions
|
||||
.filter(function(t){return recIds[t.inventory_id];})
|
||||
.map(normTx)
|
||||
.sort(function(a,b){return new Date(a.date)-new Date(b.date);});
|
||||
}
|
||||
|
||||
function brandTxs(g,bid){
|
||||
var recIds={}; g.recs.forEach(function(r){ if(r.brand&&r.brand.id===bid) recIds[r.id]=1; });
|
||||
return state.transactions
|
||||
.filter(function(t){return recIds[t.inventory_id];})
|
||||
.map(normTx)
|
||||
.sort(function(a,b){return new Date(a.date)-new Date(b.date);});
|
||||
}
|
||||
|
||||
function tlTrackHTML(txs,unitId){
|
||||
var bal=0;
|
||||
return '<div class="tl-track">'+txs.map(function(t){
|
||||
var meta=txMeta(t.type);
|
||||
bal+=t.qty;
|
||||
var selCls=(t.id===ui.selTx)?' sel':'';
|
||||
return '<div class="tl-box'+selCls+'" style="--boxc:'+meta.color+'" onclick="selectTx(\''+t.id+'\')">'
|
||||
+icon(meta.icon,'tl-ico')
|
||||
+'<div class="tl-type">'+esc(meta.label)+'</div>'
|
||||
+'<div class="tl-qty" style="color:'+meta.color+'">'+(t.qty>0?'+':'−')+fa(Math.abs(t.qty))+' '+esc(unitName(unitId))+'</div>'
|
||||
+'<div class="tl-bal">مانده: <b>'+fa(bal)+'</b> '+esc(unitName(unitId))+'</div>'
|
||||
+'</div>';
|
||||
}).join('')+'</div>';
|
||||
}
|
||||
|
||||
function timelineHTML(g){
|
||||
var compTxs=groupTxs(g);
|
||||
if(!compTxs.length) return '<div class="tl-wrap"><div class="empty-box">تراکنشی ثبت نشده است.</div></div>';
|
||||
|
||||
var specTxt=Object.keys(g.specs||{}).length?('<span class="spec-chip">'+esc(specsText(g.specs))+'</span>'):'';
|
||||
|
||||
var brands=g.brandOrder.map(function(bid){
|
||||
var act=ui.expBrand===bid;
|
||||
return '<button type="button" class="brand-btn'+(act?' act':'')+'" onclick="toggleBrand(\''+bid+'\')">'
|
||||
+esc(brandName(bid))+': <b>'+fa(g.brands[bid])+'</b> '+esc(unitName(g.unitId))
|
||||
+(act?' <span class="b-close">✕</span>':'')
|
||||
+'</button>';
|
||||
}).join('');
|
||||
|
||||
var brandSec='';
|
||||
if(ui.expBrand){
|
||||
var btxs=brandTxs(g,ui.expBrand);
|
||||
if(btxs.length){
|
||||
brandSec='<div class="tl-sub">'+icon('box','')+'جریان برند: <b>'+esc(brandName(ui.expBrand))+'</b> ('+faInt(btxs.length)+' تراکنش)</div>'
|
||||
+tlTrackHTML(btxs,g.unitId);
|
||||
}
|
||||
}
|
||||
|
||||
var detail='';
|
||||
if(ui.selTx){
|
||||
var t=state.transactions.find(function(x){return x.id===ui.selTx;});
|
||||
if(t) detail=txDetailHTML(normTx(t));
|
||||
}
|
||||
|
||||
return '<div class="tl-wrap">'
|
||||
+'<div class="tl-head"><div class="tl-title">'+icon('history','')+'خط زمانی — '+esc(matName(g.matId))
|
||||
+specTxt
|
||||
+'<span class="spec-chip">'+faInt(g.brandOrder.length)+' برند</span></div>'
|
||||
+'<div class="tl-hint">روی هر برند بزنید تا جریان همان برند باز شود • کادر پایین، جریان کلی رکورد (ترکیب همه برندها) است • کلیک روی هر کادر: جزئیات کامل</div></div>'
|
||||
+'<div class="brand-break">'+brands+'</div>'
|
||||
+brandSec
|
||||
+'<div class="tl-sub">'+icon('box','')+'جریان کلی رکورد — ترکیب همه برندها ('+faInt(compTxs.length)+' تراکنش)</div>'
|
||||
+tlTrackHTML(compTxs,g.unitId)
|
||||
+'<div class="tl-scroll-hint">↔ برای مشاهده ادامه، اسکرول کنید</div>'
|
||||
+detail
|
||||
+'</div>';
|
||||
}
|
||||
|
||||
function toggleBrand(bid){
|
||||
ui.expBrand=ui.expBrand===bid?null:bid;
|
||||
ui.selTx=null;
|
||||
var el=document.querySelector('.tl-wrap');
|
||||
if(el) el.outerHTML=timelineHTML(groupByKey(ui.expandedId));
|
||||
}
|
||||
function selectTx(id){
|
||||
ui.selTx=ui.selTx===id?null:id;
|
||||
var el=document.querySelector('.tl-wrap');
|
||||
if(el) el.outerHTML=timelineHTML(groupByKey(ui.expandedId));
|
||||
}
|
||||
|
||||
/* جزئیات فشرده یک تراکنش — فقط اطلاعات جدید */
|
||||
function txDetailHTML(t){
|
||||
var items=[];
|
||||
items.push('<span class="d-min"><span class="k">تاریخ و ساعت:</span> <b>'+esc(fmtDateTime(t.date))+'</b></span>');
|
||||
if(t.type==='TRANSFER_OUT'||t.type==='TRANSFER_IN'){
|
||||
var pair=state.transactions.find(function(x){return x.transfer_pair_id===t.pairId&&x.id!==t.id;});
|
||||
if(t.type==='TRANSFER_OUT'){
|
||||
items.push('<span class="d-min"><span class="k">از انبار:</span> <b>'+esc(t.whName)+'</b></span>');
|
||||
items.push('<span class="d-min"><span class="k">به انبار:</span> <b>'+esc(pair&&pair.warehouse?pair.warehouse.name:'—')+'</b></span>');
|
||||
}else{
|
||||
items.push('<span class="d-min"><span class="k">از انبار:</span> <b>'+esc(pair&&pair.warehouse?pair.warehouse.name:'—')+'</b></span>');
|
||||
items.push('<span class="d-min"><span class="k">به انبار:</span> <b>'+esc(t.whName)+'</b></span>');
|
||||
}
|
||||
}else{
|
||||
items.push('<span class="d-min"><span class="k">انبار:</span> <b>'+esc(t.whName)+'</b></span>');
|
||||
}
|
||||
if(t.brand&&t.brand.name) items.push('<span class="d-min"><span class="k">برند:</span> <b>'+esc(t.brand.name)+'</b></span>');
|
||||
items.push('<span class="d-min"><span class="k">انجامدهنده:</span> <b>'+esc(t.performedBy||'—')+'</b></span>');
|
||||
if(t.note) items.push('<span class="d-min"><span class="k">یادداشت:</span> <b>'+esc(t.note)+'</b></span>');
|
||||
if(t.type==='STOCK_COUNT'){
|
||||
var diff=t.physical-t.recorded;
|
||||
var cls=diff>0?'up':(diff<0?'down':'same');
|
||||
var lbl=diff>0?'+'+fa(diff):(diff<0?'−'+fa(Math.abs(diff)):'۰');
|
||||
items.push('<span class="diff-pill '+cls+'">ثبتشده: '+fa(t.recorded)+' • شمارششده: '+fa(t.physical)+' • اختلاف: '+lbl+'</span>');
|
||||
}
|
||||
return '<div class="txdetail-min" style="--boxc:'+txMeta(t.type).color+'">'
|
||||
+'<span class="d-min"><b style="color:'+txMeta(t.type).color+'">'+esc(txMeta(t.type).label)+'</b></span>'
|
||||
+items.join('')
|
||||
+'</div>';
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
کنترل روزانه
|
||||
============================================================ */
|
||||
|
||||
/* تبدیل میلادی به جلالی (الگوریتم استاندارد jalaali) */
|
||||
function jalParts(d){
|
||||
var g=new Date(d);
|
||||
var gy=g.getFullYear(), gm=g.getMonth()+1, gd=g.getDate();
|
||||
var gdm=[0,31,59,90,120,151,181,212,243,273,304,334];
|
||||
var jy=(gy<=1600)?0:979;
|
||||
gy-=(gy<=1600)?621:1600;
|
||||
var gy2=(gm>2)?(gy+1):gy;
|
||||
var days=(365*gy)+Math.floor((gy2+3)/4)-Math.floor((gy2+99)/100)+Math.floor((gy2+399)/400)-80+gd+gdm[gm-1];
|
||||
jy+=33*Math.floor(days/12053); days%=12053;
|
||||
jy+=4*Math.floor(days/1461); days%=1461;
|
||||
if(days>365){ jy+=Math.floor((days-1)/365); days=(days-1)%365; }
|
||||
var jm=(days<186)?1+Math.floor(days/31):7+Math.floor((days-186)/30);
|
||||
var jd=1+((days<186)?(days%31):((days-186)%30));
|
||||
return {year:jy, month:jm, day:jd};
|
||||
}
|
||||
|
||||
function jalMonthTitle(d){
|
||||
var p=jalParts(d);
|
||||
var months=['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'];
|
||||
return faInt(p.year)+' '+months[p.month-1];
|
||||
}
|
||||
|
||||
function txByDayMap(){
|
||||
var map={};
|
||||
state.transactions.forEach(function(t){
|
||||
var k=gregKey(new Date(t.transaction_date));
|
||||
if(!map[k]) map[k]=[];
|
||||
map[k].push(t);
|
||||
});
|
||||
Object.keys(map).forEach(function(k){
|
||||
map[k].sort(function(a,b){return new Date(a.transaction_date)-new Date(b.transaction_date);});
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function renderCalendar(){
|
||||
var anchor=ui.cal.anchor;
|
||||
var target=jalParts(anchor);
|
||||
var first=null;
|
||||
for(var i=0;i<=33;i++){
|
||||
var d=new Date(anchor.getTime()-i*86400000);
|
||||
var p=jalParts(d);
|
||||
if(p.year!==target.year||p.month!==target.month){
|
||||
first=new Date(anchor.getTime()-(i-1)*86400000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!first) first=new Date(anchor.getTime()-33*86400000);
|
||||
var offset=(first.getDay()+1)%7;
|
||||
var gridStart=new Date(first.getTime()-offset*86400000);
|
||||
var txMap=txByDayMap();
|
||||
var tk=todayKey();
|
||||
|
||||
var cells='';
|
||||
for(var c=0;c<42;c++){
|
||||
var day=new Date(gridStart.getTime()+c*86400000);
|
||||
var jp=jalParts(day);
|
||||
var k=gregKey(day);
|
||||
var inMonth=(jp.month===target.month&&jp.year===target.year);
|
||||
var n=(txMap[k]||[]).length;
|
||||
var cls='day-cell'+(inMonth?'':' out')+(k===tk?' today':'')+(k===ui.cal.sel?' sel':'');
|
||||
cells+='<button class="'+cls+'" onclick="selectDay(\''+k+'\')">'
|
||||
+faInt(jp.day)
|
||||
+(n>0?'<span class="d-dot">'+faInt(n)+'</span>':'')
|
||||
+'</button>';
|
||||
}
|
||||
|
||||
var dow='<div class="cal-dow">ش</div><div class="cal-dow">ی</div><div class="cal-dow">د</div><div class="cal-dow">س</div><div class="cal-dow">چ</div><div class="cal-dow">پ</div><div class="cal-dow">ج</div>';
|
||||
|
||||
var calCard='<div class="cal-card">'
|
||||
+'<div class="cal-head">'
|
||||
+'<div class="month">'+icon('calendar','')+esc(jalMonthTitle(anchor))+'</div>'
|
||||
+'<div class="cal-nav">'
|
||||
+'<button onclick="calShift(1)" title="ماه بعد">◀</button>'
|
||||
+'<button onclick="calToday()" title="برو به امروز" style="width:auto;padding:0 10px;font-size:11.5px">امروز</button>'
|
||||
+'<button onclick="calShift(-1)" title="ماه قبل">▶</button>'
|
||||
+'</div></div>'
|
||||
+'<div class="cal-grid">'+dow+cells+'</div>'
|
||||
+'</div>';
|
||||
|
||||
document.getElementById('main').innerHTML='<div class="cal-layout">'+calCard+dayPanelHTML(txMap)+'</div>';
|
||||
}
|
||||
|
||||
function calShift(dir){
|
||||
var a=new Date(ui.cal.anchor.getTime());
|
||||
a.setDate(a.getDate()+dir*33);
|
||||
ui.cal.anchor=a;
|
||||
ui.cal.actRec=null;
|
||||
renderCalendar();
|
||||
}
|
||||
function calToday(){
|
||||
ui.cal.anchor=new Date();
|
||||
ui.cal.sel=todayKey();
|
||||
ui.cal.actRec=null;
|
||||
renderCalendar();
|
||||
}
|
||||
function selectDay(k){
|
||||
ui.cal.sel=k;
|
||||
ui.cal.actRec=null;
|
||||
renderCalendar();
|
||||
}
|
||||
function setCalAct(id){
|
||||
ui.cal.actRec=ui.cal.actRec===id?null:id;
|
||||
renderCalendar();
|
||||
}
|
||||
|
||||
/* برداشتهای یک روز — گروهبندی بر اساس رکورد (ماده اولیه+برند+مشخصه) */
|
||||
function dayWithdrawalGroups(k,txMap){
|
||||
var list=(txMap[k]||[]).filter(function(t){return t.transaction_type==='WITHDRAWAL';});
|
||||
var groups={},order=[];
|
||||
list.forEach(function(t){
|
||||
var g=groups[t.inventory_id];
|
||||
if(!g){ g={recId:t.inventory_id,q:0,last:t.transaction_date}; groups[t.inventory_id]=g; order.push(t.inventory_id); }
|
||||
g.q+=Math.abs(Number(t.quantity_number)||0);
|
||||
if(t.transaction_date>g.last) g.last=t.transaction_date;
|
||||
});
|
||||
return order.map(function(id){return groups[id];});
|
||||
}
|
||||
|
||||
function dayPanelHTML(txMap){
|
||||
var k=ui.cal.sel;
|
||||
var dateObj=new Date(k.split('-')[0],Number(k.split('-')[1])-1,Number(k.split('-')[2]));
|
||||
var groups=dayWithdrawalGroups(k,txMap);
|
||||
var isToday=k===todayKey();
|
||||
|
||||
var list='';
|
||||
if(!groups.length){
|
||||
list='<div class="rows-empty">'
|
||||
+(isToday?'امروز هنوز برداشتی ثبت نشده است.':'در این روز برداشتی ثبت نشده است.')
|
||||
+' هر برداشتی که در «تغییر موجودی» ثبت کنید، همینجا نمایش داده میشود.</div>';
|
||||
}else{
|
||||
list=groups.map(function(g){
|
||||
var t=txMap[k].find(function(x){return x.inventory_id===g.recId;});
|
||||
if(!t) return '';
|
||||
var wh=t.warehouse||{name:'—'};
|
||||
var active=ui.cal.actRec===g.recId;
|
||||
var sub=[];
|
||||
if(t.brand&&t.brand.name) sub.push(esc(t.brand.name));
|
||||
if(Object.keys(t.specs||{}).length) sub.push(esc(specsText(t.specs)));
|
||||
sub.push(esc(wh.name));
|
||||
var matJs=esc(t.material&&t.material.name?t.material.name:'—');
|
||||
var unitTxt=t.unit?(t.unit.name_fa||t.unit.code):'';
|
||||
var actions='';
|
||||
if(active&&isKeeper()){
|
||||
actions='<div class="dp-actions">'
|
||||
+'<button class="act-ret" onclick="quickChange(\'RETURN\',\''+t.warehouse_id+'\',\''+matJs+'\')">'+icon('undo','')+'برگشت</button>'
|
||||
+'<button class="act-out" onclick="quickChange(\'LOSS\',\''+t.warehouse_id+'\',\''+matJs+'\')">'+icon('pkgX','')+'مفقودی</button>'
|
||||
+'<button class="act-out" onclick="quickChange(\'WASTE\',\''+t.warehouse_id+'\',\''+matJs+'\')">'+icon('trash','')+'اتلاف</button>'
|
||||
+'</div>';
|
||||
}
|
||||
return '<div class="dp-item'+(active?' act':'')+'">'
|
||||
+'<button type="button" class="dp-hit" onclick="setCalAct(\''+g.recId+'\')">'
|
||||
+'<div class="badge" style="background:'+hexBg(TX.WITHDRAWAL.color,.13)+';color:'+TX.WITHDRAWAL.color+'">'+icon('minusCirc','')+'</div>'
|
||||
+'<div class="main"><div class="t">'+matJs+'</div><div class="s">'+sub.join(' · ')+'</div></div>'
|
||||
+'<div class="qty out">−'+fa(g.q)+' '+esc(unitTxt)+'</div>'
|
||||
+'<div class="time">'+esc(fmtTime(g.last))+'</div>'
|
||||
+'</button>'
|
||||
+actions
|
||||
+'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
return '<div class="day-panel">'
|
||||
+'<div class="dp-head"><h2>'+icon('history','')+'کنترل روزانه — '+(isToday?'امروز':esc(fmtDateLong(dateObj)))+'</h2>'
|
||||
+'<span class="dp-chip out">▼ برداشتها: '+faInt(groups.length)+' قلم</span>'
|
||||
+'</div>'
|
||||
+'<div class="dp-hint">مواردی که این روز از انبار برداشت و به ساختمان برده شدهاند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید.</div>'
|
||||
+'<div class="dp-list">'+list+'</div>'
|
||||
+'</div>';
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
انبارها
|
||||
============================================================ */
|
||||
|
||||
function renderWarehouses(){
|
||||
var whs=scopedWarehouses();
|
||||
var html='';
|
||||
|
||||
if(isAdmin()){
|
||||
html+='<div class="hint-box">'+icon('lock','')+' نقش ادمین فقطخواندنی است — مدیریت انبارها با مدیران پروژه است.</div>';
|
||||
}
|
||||
|
||||
/* فرم افزودن انبار — مدیر پروژه به پروژه خودش وصل میشود */
|
||||
if(isKeeper()){
|
||||
html+='<div class="wh-add card">'
|
||||
+'<h3>'+icon('plus','')+'افزودن انبار جدید</h3>'
|
||||
+'<div class="wh-add-row">'
|
||||
+'<input type="text" id="wh-new-name" placeholder="نام انبار (مثلاً انبار موقت)" value="'+esc(ui.whNewName||'')+'" oninput="ui.whNewName=this.value">'
|
||||
+'<input type="text" id="wh-new-desc" placeholder="توضیحات (اختیاری)" value="'+esc(ui.whNewDesc||'')+'" oninput="ui.whNewDesc=this.value">'
|
||||
+'<button class="mini-btn" onclick="addWarehouse()">'+icon('plus','')+'افزودن</button>'
|
||||
+'</div></div>';
|
||||
}
|
||||
|
||||
html+='<div class="wh-grid">'+whs.map(function(w){
|
||||
var cnt=state.inventory.filter(function(r){return r.warehouse&&r.warehouse.id===w.id;}).length;
|
||||
var txc=state.transactions.filter(function(t){return t.warehouse_id===w.id;}).length;
|
||||
|
||||
var edit='';
|
||||
if(ui.whEditId===w.id&&isKeeper()){
|
||||
edit='<div class="wh-edit">'
|
||||
+'<input type="text" value="'+esc(ui.whEditName)+'" oninput="ui.whEditName=this.value" placeholder="نام انبار">'
|
||||
+'<input type="text" value="'+esc(ui.whEditDesc)+'" oninput="ui.whEditDesc=this.value" placeholder="توضیحات">'
|
||||
+'<div style="display:flex; gap:8px">'
|
||||
+'<button class="mini-btn" onclick="saveWarehouseEdit(\''+w.id+'\')">ذخیره</button>'
|
||||
+'<button class="ghost-btn" onclick="ui.whEditId=null; renderWarehouses()">انصراف</button>'
|
||||
+'</div></div>';
|
||||
}
|
||||
|
||||
var actions='';
|
||||
if(isKeeper()){
|
||||
actions='<div class="wh-actions">'
|
||||
+'<button onclick="startWhEdit(\''+w.id+'\')">'+icon('pencil','')+'ویرایش</button>'
|
||||
+'<button onclick="deleteWarehouse(\''+w.id+'\')">'+icon('x','')+'حذف</button>'
|
||||
+'</div>';
|
||||
}
|
||||
|
||||
return '<div class="wh-card" style="cursor:pointer" title="مشاهده در داشبورد" onclick="goWarehouseDashboard(\''+w.id+'\')">'
|
||||
+'<h3>'+icon('warehouse','')+esc(w.name)+'</h3>'
|
||||
+'<div class="desc">'+esc(w.description||'—')+'</div>'
|
||||
+'<div class="wh-meta">'
|
||||
+'<span>پروژه: <b>'+esc(projName(w.project_id))+'</b></span>'
|
||||
+'<span>اقلام موجود: <b>'+faInt(cnt)+'</b></span>'
|
||||
+'<span>تراکنشها: <b>'+faInt(txc)+'</b></span>'
|
||||
+'<span>تاریخ ایجاد: <b>'+esc(fmtDate(w.created_at))+'</b></span>'
|
||||
+'</div>'
|
||||
+actions
|
||||
+edit
|
||||
+'</div>';
|
||||
}).join('')+'</div>';
|
||||
|
||||
document.getElementById('main').innerHTML=html;
|
||||
}
|
||||
|
||||
function addWarehouse(projId){
|
||||
var proj=projId||(isKeeper()?user().projectId:'');
|
||||
var name=(ui.whNewName||'').trim();
|
||||
if(!name){ toast('نام انبار الزامی است','err'); return; }
|
||||
if(!proj){ toast('پروژه انبار را انتخاب کنید','err'); return; }
|
||||
apiPost('/inventory/warehouses', {project_id:proj, name:name, description:(ui.whNewDesc||'').trim()})
|
||||
.then(function(){
|
||||
ui.whNewName=''; ui.whNewDesc='';
|
||||
toast('انبار «'+name+'» اضافه شد ✓','ok');
|
||||
return refreshData();
|
||||
}).catch(function(e){ toast(e.message,'err'); });
|
||||
}
|
||||
|
||||
function startWhEdit(id){
|
||||
var w=whById(id);
|
||||
if(!w) return;
|
||||
ui.whEditId=id; ui.whEditName=w.name; ui.whEditDesc=w.description||'';
|
||||
renderWarehouses();
|
||||
}
|
||||
function saveWarehouseEdit(id){
|
||||
var name=ui.whEditName.trim();
|
||||
if(!name){ toast('نام انبار نمیتواند خالی باشد','err'); return; }
|
||||
apiPatch('/inventory/warehouses/'+id, {name:name, description:ui.whEditDesc.trim()})
|
||||
.then(function(){
|
||||
ui.whEditId=null;
|
||||
toast('تغییرات ذخیره شد','ok');
|
||||
return refreshData();
|
||||
}).catch(function(e){ toast(e.message,'err'); });
|
||||
}
|
||||
function deleteWarehouse(id){
|
||||
if(!confirm('انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)')) return;
|
||||
apiDelete('/inventory/warehouses/'+id)
|
||||
.then(function(){ toast('انبار حذف شد','ok'); return refreshData(); })
|
||||
.catch(function(e){ toast(e.message,'err'); });
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
سازماندهی (فقط ادمین) — پروژه، مدیران و انبارهای هر پروژه
|
||||
============================================================ */
|
||||
|
||||
function renderOrg(){
|
||||
if(!isAdmin()) return;
|
||||
var html='<div class="hint-box">'+icon('info','')
|
||||
+' پروژهها از سیستم تدارکات میآیند. اینجا مشخص میکنید هر پروژه با کدام مدیر(ان) و انبار(ها) کار میکند؛ هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرمها اعمال میشود.</div>';
|
||||
|
||||
html+='<div class="org-grid">'+state.projects.map(function(p){
|
||||
var pms=state.users.filter(function(u){return u.role==='keeper'&&String(u.projectId)===String(p.id);});
|
||||
var whs=state.warehouses.filter(function(w){return String(w.project_id)===String(p.id);});
|
||||
var pk='p'+p.id.replace(/-/g,'').slice(0,6);
|
||||
|
||||
/* مدیران پروژه */
|
||||
var pmHtml=pms.map(function(u){
|
||||
var delBtn=(u.pw!==session.pw)
|
||||
?'<button class="org-del" onclick="deleteUser(\''+u.pw+'\')" title="حذف مدیر">'+icon('x','')+'</button>':'';
|
||||
if(ui.pmEditId===u.pw){
|
||||
return '<div class="org-chip org-edit">'
|
||||
+'<input type="text" placeholder="نام مدیر" value="'+esc(ui.pmEditName)+'" oninput="ui.pmEditName=this.value">'
|
||||
+'<input type="text" placeholder="رمز ورود" value="'+esc(ui.pmEditPw)+'" oninput="ui.pmEditPw=this.value">'
|
||||
+'<input type="text" placeholder="ایمیل حساب سازمانی" value="'+esc(ui.pmEditEmail)+'" oninput="ui.pmEditEmail=this.value">'
|
||||
+'<select onchange="ui.pmEditProj=this.value">'
|
||||
+state.projects.map(function(p2){return '<option value="'+p2.id+'"'+(String(p2.id)===String(ui.pmEditProj)?' selected':'')+'>'+esc(p2.name)+'</option>';}).join('')
|
||||
+'</select>'
|
||||
+'<button class="mini-btn" onclick="savePMEdit(\''+u.pw+'\')">'+icon('check','')+'ذخیره</button>'
|
||||
+'<button class="ghost-btn" onclick="ui.pmEditId=null; renderOrg()">انصراف</button>'
|
||||
+'</div>';
|
||||
}
|
||||
return '<div class="org-chip">'+icon('user','')
|
||||
+'<span class="nm">'+esc(u.name)+'</span>'
|
||||
+'<span class="pw">رمز: '+esc(u.pw)+'</span>'
|
||||
+(u.email?'<span class="pw" title="ایمیل حساب سازمانی">'+esc(u.email)+'</span>':'')
|
||||
+'<button class="org-del" style="color:var(--accent-dark)" onclick="startPMEdit(\''+u.pw+'\')" title="ویرایش مدیر">'+icon('pencil','')+'</button>'
|
||||
+delBtn
|
||||
+'</div>';
|
||||
}).join('');
|
||||
if(!pms.length) pmHtml='<div class="org-none">مدیری تعیین نشده است — از فرم زیر اضافه کنید.</div>';
|
||||
|
||||
/* انبارهای پروژه */
|
||||
var whHtml=whs.map(function(w){
|
||||
return '<div class="org-wh">'
|
||||
+'<div class="info"><b>'+esc(w.name)+'</b><small>'+esc(w.description||'')+'</small></div>'
|
||||
+'<select onchange="moveWarehouse(\''+w.id+'\',this.value)" title="انتقال به پروژه دیگر">'
|
||||
+state.projects.map(function(p2){return '<option value="'+p2.id+'"'+(String(p2.id)===String(w.project_id)?' selected':'')+'>'+esc(p2.name)+'</option>';}).join('')
|
||||
+'</select>'
|
||||
+'<button class="org-del" onclick="deleteWarehouse(\''+w.id+'\')" title="حذف انبار">'+icon('x','')+'</button>'
|
||||
+'</div>';
|
||||
}).join('');
|
||||
if(!whs.length) whHtml='<div class="org-none">انباری ندارد — از فرم زیر اضافه کنید.</div>';
|
||||
|
||||
return '<div class="org-card">'
|
||||
+'<div class="org-head">'+icon('building','')+'<h3>'+esc(p.name)+'</h3>'
|
||||
+'<span class="org-badge">'+faInt(pms.length)+' مدیر · '+faInt(whs.length)+' انبار</span></div>'
|
||||
+'<div class="org-sec"><h4>'+icon('user','')+'مدیران پروژه</h4>'+pmHtml
|
||||
+'<div class="org-add">'
|
||||
+'<input type="text" placeholder="نام مدیر" value="'+esc(ui['pmName'+pk]||'')+'" oninput="ui.pmName'+pk+'=this.value">'
|
||||
+'<input type="text" placeholder="رمز ورود" value="'+esc(ui['pmPw'+pk]||'')+'" oninput="ui.pmPw'+pk+'=this.value">'
|
||||
+'<input type="text" placeholder="ایمیل حساب سازمانی" value="'+esc(ui['pmEmail'+pk]||'')+'" oninput="ui.pmEmail'+pk+'=this.value">'
|
||||
+'<button class="mini-btn" onclick="addUser(\''+p.id+'\')">'+icon('plus','')+'افزودن مدیر</button>'
|
||||
+'</div></div>'
|
||||
+'<div class="org-sec"><h4>'+icon('warehouse','')+'انبارهای پروژه</h4>'+whHtml
|
||||
+'<div class="org-add">'
|
||||
+'<input type="text" placeholder="نام انبار جدید" value="'+esc(ui['whName'+pk]||'')+'" oninput="ui.whName'+pk+'=this.value">'
|
||||
+'<input type="text" placeholder="توضیحات (اختیاری)" value="'+esc(ui['whDesc'+pk]||'')+'" oninput="ui.whDesc'+pk+'=this.value">'
|
||||
+'<button class="mini-btn" onclick="addWarehouse(\''+p.id+'\')">'+icon('plus','')+'افزودن انبار</button>'
|
||||
+'</div></div>'
|
||||
+'</div>';
|
||||
}).join('')+'</div>';
|
||||
|
||||
document.getElementById('main').innerHTML=html;
|
||||
}
|
||||
|
||||
function addProject(){
|
||||
var name=(ui.whNewName||'').trim();
|
||||
if(!name){ toast('نام پروژه الزامی است','err'); return; }
|
||||
apiPost('/projects', {name:name, description:(ui.whNewDesc||'').trim()})
|
||||
.then(function(){
|
||||
ui.whNewName=''; ui.whNewDesc='';
|
||||
toast('پروژه «'+name+'» اضافه شد ✓','ok');
|
||||
return refreshData();
|
||||
}).catch(function(e){ toast(e.message,'err'); });
|
||||
}
|
||||
|
||||
function addUser(projId){
|
||||
var pk='p'+projId.replace(/-/g,'').slice(0,6);
|
||||
var name=(ui['pmName'+pk]||'').trim();
|
||||
var pw=(ui['pmPw'+pk]||'').trim();
|
||||
var email=(ui['pmEmail'+pk]||'').trim();
|
||||
if(!name){ toast('نام مدیر پروژه الزامی است','err'); return; }
|
||||
if(!pw){ toast('رمز ورود الزامی است','err'); return; }
|
||||
if(state.users.some(function(u){return u.pw===pw;})){
|
||||
toast('این رمز ورود قبلاً استفاده شده است','err'); return;
|
||||
}
|
||||
if(email&&state.users.some(function(u){return u.email&&String(u.email).trim().toLowerCase()===email.toLowerCase();})){
|
||||
toast('این ایمیل حساب سازمانی قبلاً ثبت شده است','err'); return;
|
||||
}
|
||||
var proj=state.projects.find(function(x){return String(x.id)===String(projId);});
|
||||
state.users.push({pw:pw, role:'keeper', name:name, label:'مدیر پروژه', projectId:projId,
|
||||
projectName:proj?proj.name:'', email:email||''});
|
||||
ui['pmName'+pk]=''; ui['pmPw'+pk]=''; ui['pmEmail'+pk]='';
|
||||
saveUsers();
|
||||
toast('مدیر پروژه «'+name+'» اضافه شد ✓','ok');
|
||||
renderOrg();
|
||||
}
|
||||
|
||||
function deleteUser(pw){
|
||||
var u=state.users.find(function(x){return x.pw===pw;});
|
||||
if(!u) return;
|
||||
if(u.role==='admin'||u.pw===session.pw){ toast('حذف این کاربر ممکن نیست','err'); return; }
|
||||
if(!confirm('کاربر «'+u.name+'» حذف شود؟')) return;
|
||||
state.users=state.users.filter(function(x){return x.pw!==pw;});
|
||||
saveUsers();
|
||||
toast('کاربر حذف شد','ok');
|
||||
renderOrg();
|
||||
}
|
||||
|
||||
/* ویرایش اطلاعات مدیر پروژه */
|
||||
function startPMEdit(pw){
|
||||
var u=state.users.find(function(x){return x.pw===pw;});
|
||||
if(!u) return;
|
||||
ui.pmEditId=pw; ui.pmEditName=u.name; ui.pmEditPw=u.pw; ui.pmEditProj=u.projectId;
|
||||
ui.pmEditEmail=u.email||'';
|
||||
renderOrg();
|
||||
}
|
||||
|
||||
function savePMEdit(pw){
|
||||
var u=state.users.find(function(x){return x.pw===pw;});
|
||||
if(!u) return;
|
||||
var name=(ui.pmEditName||'').trim();
|
||||
var newPw=(ui.pmEditPw||'').trim();
|
||||
var proj=ui.pmEditProj||u.projectId;
|
||||
var email=(ui.pmEditEmail||'').trim();
|
||||
if(!name){ toast('نام مدیر الزامی است','err'); return; }
|
||||
if(!newPw){ toast('رمز ورود الزامی است','err'); return; }
|
||||
if(newPw!==u.pw&&state.users.some(function(x){return x.pw===newPw;})){
|
||||
toast('این رمز ورود قبلاً استفاده شده است','err'); return;
|
||||
}
|
||||
if(email&&state.users.some(function(x){return x.pw!==pw&&x.email&&String(x.email).trim().toLowerCase()===email.toLowerCase();})){
|
||||
toast('این ایمیل حساب سازمانی قبلاً ثبت شده است','err'); return;
|
||||
}
|
||||
var proj2=state.projects.find(function(x){return String(x.id)===String(proj);});
|
||||
u.name=name; u.pw=newPw; u.projectId=proj; u.projectName=proj2?proj2.name:''; u.email=email;
|
||||
ui.pmEditId=null;
|
||||
saveUsers();
|
||||
toast('اطلاعات مدیر بهروزرسانی شد ✓','ok');
|
||||
renderOrg();
|
||||
}
|
||||
|
||||
/* انتقال انبار به پروژه دیگر */
|
||||
function moveWarehouse(id,projId){
|
||||
apiPatch('/inventory/warehouses/'+id, {project_id:projId})
|
||||
.then(function(){ toast('انبار منتقل شد ✓','ok'); return refreshData(); })
|
||||
.catch(function(e){ toast(e.message,'err'); });
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
تازهسازی + render
|
||||
============================================================ */
|
||||
|
||||
function refreshData(){
|
||||
return loadAll().then(function(ok){ if(ok) renderPage(); return ok; });
|
||||
}
|
||||
|
||||
/* کلیک روی کارت انبار: رفتن به داشبورد با فیلتر همان انبار */
|
||||
function goWarehouseDashboard(id){
|
||||
ui.filters={category:'',warehouse:id,material:''};
|
||||
ui.expandedId=null; ui.selTx=null; ui.expBrand=null;
|
||||
ui.view='dashboard';
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function renderAll(){
|
||||
if(!user()) return;
|
||||
renderNav();
|
||||
renderSession();
|
||||
if(ui.view==='dashboard') renderDashboard();
|
||||
else if(ui.view==='calendar') renderCalendar();
|
||||
else if(ui.view==='users') renderOrg();
|
||||
else renderWarehouses();
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
boot
|
||||
============================================================ */
|
||||
|
||||
loadUsers();
|
||||
var session=loadSession();
|
||||
if(session){
|
||||
document.getElementById('login').classList.add('hidden');
|
||||
document.getElementById('app').classList.remove('hidden');
|
||||
if(typeof location!=='undefined'&&location.hash){
|
||||
if(location.hash==='#calendar') ui.view='calendar';
|
||||
else if(location.hash==='#warehouses') ui.view='warehouses';
|
||||
else if(location.hash==='#users') ui.view='users';
|
||||
}
|
||||
renderChrome();
|
||||
loadAll().then(function(ok){ renderAll(); });
|
||||
}else{
|
||||
document.getElementById('app').classList.add('hidden');
|
||||
document.getElementById('login').classList.remove('hidden');
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,19 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""سرور استاتیک محلی بدون کش (جایگزین py -m http.server)
|
||||
اجرا: py serve.py
|
||||
"""
|
||||
import http.server
|
||||
import socketserver
|
||||
|
||||
PORT = 8123
|
||||
|
||||
|
||||
class NoCacheHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
super().end_headers()
|
||||
|
||||
|
||||
with socketserver.TCPServer(('', PORT), NoCacheHandler) as httpd:
|
||||
print('Serving on http://localhost:%d (no-cache)' % PORT)
|
||||
httpd.serve_forever()
|
||||
@@ -1,685 +0,0 @@
|
||||
:root{
|
||||
--bg:#f3f5f9; --panel:#ffffff; --ink:#0f172a; --muted:#64748b; --line:#e5e9f0;
|
||||
--accent:#4f46e5; --accent-dark:#4338ca; --accent-bg:#eef2ff;
|
||||
--green:#16a34a; --green-bg:#dcfce7;
|
||||
--blue:#1d4ed8; --blue-bg:#dbeafe;
|
||||
--indigo:#4f46e5; --indigo-bg:#e0e7ff;
|
||||
--red:#dc2626; --red-bg:#fee2e2;
|
||||
--orange:#c2410c; --orange-bg:#ffedd5;
|
||||
--slate:#64748b; --slate-bg:#f1f5f9;
|
||||
}
|
||||
*{box-sizing:border-box; margin:0; padding:0;}
|
||||
html{font-size:14px;}
|
||||
body{
|
||||
font-family:'Vazirmatn',Tahoma,'Segoe UI',sans-serif;
|
||||
background:var(--bg); color:var(--ink);
|
||||
min-height:100vh; line-height:1.7;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
button{font-family:inherit; cursor:pointer;}
|
||||
input,select,textarea{font-family:inherit; font-size:13px; color:var(--ink);}
|
||||
svg{width:16px; height:16px; flex:none;} /* اندازه پیشفرض همه آیکونها */
|
||||
.hidden{display:none !important;}
|
||||
|
||||
/* ---------- login ---------- */
|
||||
#login{
|
||||
position:fixed; inset:0; background:linear-gradient(160deg,#eef2ff 0%,#f3f5f9 55%,#e0e7ff 100%);
|
||||
display:flex; align-items:center; justify-content:center; padding:20px; z-index:200;
|
||||
}
|
||||
.login-card{
|
||||
background:#fff; border:1px solid var(--line); border-radius:16px;
|
||||
padding:34px 30px 28px; width:min(420px,94vw); text-align:center;
|
||||
box-shadow:0 20px 50px rgba(15,23,42,.12);
|
||||
}
|
||||
.login-logo{
|
||||
width:60px; height:60px; margin:0 auto 12px; border-radius:16px; background:var(--accent);
|
||||
color:#fff; display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.login-logo svg{width:30px; height:30px;}
|
||||
.login-card h1{font-size:18px; font-weight:800; margin-bottom:4px;}
|
||||
.login-card .sub{font-size:12px; color:var(--muted); margin-bottom:20px;}
|
||||
.login-card label{display:block; text-align:right; font-size:12px; font-weight:700; color:var(--muted); margin-bottom:6px;}
|
||||
.login-card input{
|
||||
width:100%; border:1px solid var(--line); border-radius:10px; padding:11px 14px;
|
||||
font-size:14px; outline:none; text-align:center; letter-spacing:2px; direction:ltr;
|
||||
transition:all .15s;
|
||||
}
|
||||
.login-card input:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.login-btn{
|
||||
width:100%; margin-top:14px; background:var(--accent); color:#fff; border:none;
|
||||
font-size:14px; font-weight:700; padding:11px; border-radius:10px;
|
||||
display:flex; align-items:center; justify-content:center; gap:8px;
|
||||
}
|
||||
.login-btn:hover{background:var(--accent-dark);}
|
||||
.login-btn svg{width:16px; height:16px;}
|
||||
.login-divider{
|
||||
display:flex; align-items:center; gap:10px; margin-top:14px;
|
||||
color:var(--muted); font-size:11px; font-weight:600;
|
||||
}
|
||||
.login-divider::before,.login-divider::after{content:""; flex:1; height:1px; background:var(--line);}
|
||||
.casdoor-btn{
|
||||
background:#0ea5e9; box-shadow:0 2px 8px rgba(14,165,233,.3);
|
||||
}
|
||||
.casdoor-btn:hover{background:#0284c7 !important;}
|
||||
.login-err{color:var(--red); font-size:12px; font-weight:600; margin-top:10px; min-height:18px;}
|
||||
.demo-hint{
|
||||
margin-top:18px; padding:10px 12px; background:#f8fafc; border:1px dashed #cbd5e1;
|
||||
border-radius:10px; font-size:11px; color:var(--muted); text-align:right; line-height:2;
|
||||
}
|
||||
.demo-hint b{color:var(--ink);}
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
.topbar{
|
||||
background:var(--panel); border-bottom:1px solid var(--line);
|
||||
padding:0 22px; display:flex; align-items:center; gap:20px; flex-wrap:wrap;
|
||||
position:sticky; top:0; z-index:50;
|
||||
}
|
||||
.brand{display:flex; align-items:center; gap:12px; padding:10px 0;}
|
||||
.brand .logo{
|
||||
width:38px; height:38px; border-radius:10px; background:var(--accent); color:#fff;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.brand .logo svg{width:20px; height:20px;}
|
||||
.brand h1{font-size:16px; font-weight:800;}
|
||||
.brand .sub{font-size:11px; color:var(--muted);}
|
||||
.nav{display:flex; gap:6px; margin-inline-start:auto; flex-wrap:wrap;}
|
||||
.nav-btn{
|
||||
display:flex; align-items:center; gap:7px; border:1px solid var(--line);
|
||||
background:var(--panel); color:var(--ink); padding:7px 14px; border-radius:9px;
|
||||
font-size:13px; font-weight:500; transition:all .15s;
|
||||
}
|
||||
.nav-btn:hover{border-color:#c7d2fe; background:var(--accent-bg);}
|
||||
.nav-btn.active{background:var(--accent); border-color:var(--accent); color:#fff;}
|
||||
.nav-btn svg{width:16px; height:16px;}
|
||||
.top-actions{margin-inline-start:auto;}
|
||||
.ghost-btn{
|
||||
display:inline-flex; align-items:center; gap:6px; border:1px dashed #cbd5e1;
|
||||
background:transparent; color:var(--muted); padding:6px 12px; border-radius:8px; font-size:12px;
|
||||
}
|
||||
.ghost-btn:hover{color:var(--red); border-color:#fca5a5; background:#fff5f5;}
|
||||
.ghost-btn svg{width:14px; height:14px;}
|
||||
|
||||
/* دکمه «تغییر موجودی» گوشه بالا-چپ صفحه اصلی */
|
||||
.change-link{
|
||||
display:inline-flex; align-items:center; gap:7px; text-decoration:none;
|
||||
background:var(--accent); color:#fff; border:1px solid var(--accent);
|
||||
border-radius:10px; padding:7px 14px; font-size:12.5px; font-weight:700;
|
||||
transition:all .15s;
|
||||
}
|
||||
.change-link:hover{background:var(--accent-dark); border-color:var(--accent-dark); transform:translateY(-1px); box-shadow:0 3px 8px rgba(79,70,229,.25);}
|
||||
.change-link svg{width:14px; height:14px;}
|
||||
|
||||
/* دکمه «برگشت به داشبوردها» صفحه تغییر موجودی */
|
||||
.change-back{
|
||||
display:inline-flex; align-items:center; gap:7px; text-decoration:none;
|
||||
background:#fff; color:var(--ink); border:1px solid var(--line);
|
||||
border-radius:10px; padding:7px 14px; font-size:12.5px; font-weight:700;
|
||||
transition:all .15s;
|
||||
}
|
||||
.change-back:hover{border-color:var(--accent); color:var(--accent-dark); background:var(--accent-bg);}
|
||||
.change-back svg{width:14px; height:14px;}
|
||||
|
||||
/* ---------- session bar ---------- */
|
||||
.sessionbar{
|
||||
background:var(--panel); border-bottom:1px solid var(--line);
|
||||
padding:9px 22px; display:flex; align-items:center; gap:14px; flex-wrap:wrap;
|
||||
}
|
||||
.sb-field{display:flex; align-items:center; gap:7px; font-size:12px; color:var(--muted);}
|
||||
.sb-field svg{width:14px; height:14px;}
|
||||
.sb-field label{font-weight:600; white-space:nowrap;}
|
||||
.sb-field select{
|
||||
border:1px solid var(--line); border-radius:8px; background:#fff;
|
||||
padding:6px 10px; font-size:12.5px; min-width:130px; outline:none;
|
||||
}
|
||||
.sb-field select:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.user-chip{
|
||||
display:inline-flex; align-items:center; gap:8px; background:var(--accent-bg);
|
||||
color:var(--accent-dark); border:1px solid #c7d2fe; font-size:12px; font-weight:700;
|
||||
padding:5px 14px; border-radius:999px;
|
||||
}
|
||||
.idp-chip{
|
||||
display:inline-flex; align-items:center; gap:6px; background:#e0f2fe;
|
||||
color:#0369a1; border:1px solid #bae6fd; font-size:11px; font-weight:700;
|
||||
padding:4px 12px; border-radius:999px;
|
||||
}
|
||||
.idp-chip svg{width:12px; height:12px;}
|
||||
.user-chip svg{width:13px; height:13px;}
|
||||
.readonly-chip{
|
||||
display:inline-flex; align-items:center; gap:6px; background:#fff7ed;
|
||||
color:var(--orange); border:1px solid #fed7aa; font-size:12px; font-weight:600;
|
||||
padding:5px 12px; border-radius:999px;
|
||||
}
|
||||
.readonly-chip svg{width:13px; height:13px;}
|
||||
.logout-btn{
|
||||
margin-inline-start:auto; display:inline-flex; align-items:center; gap:6px;
|
||||
border:1px solid var(--line); background:#fff; color:var(--red);
|
||||
font-size:12px; font-weight:600; padding:6px 14px; border-radius:8px;
|
||||
}
|
||||
.logout-btn:hover{background:var(--red-bg); border-color:#fca5a5;}
|
||||
.logout-btn svg{width:13px; height:13px;}
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
main{max-width:1280px; margin:0 auto; padding:20px 22px 60px;}
|
||||
.card{background:var(--panel); border:1px solid var(--line); border-radius:12px; overflow:hidden;}
|
||||
.card-head{
|
||||
padding:13px 18px; border-bottom:1px solid var(--line);
|
||||
display:flex; align-items:center; gap:12px; flex-wrap:wrap;
|
||||
}
|
||||
.card-head h2{font-size:15px; font-weight:700; display:flex; align-items:center; gap:8px;}
|
||||
.card-head h2 svg{width:17px; height:17px; color:var(--accent);}
|
||||
.card-body{padding:16px 18px;}
|
||||
|
||||
/* ---------- dashboard filters ---------- */
|
||||
.filterbar{display:flex; align-items:flex-end; gap:10px; flex-wrap:wrap; margin-bottom:14px;}
|
||||
.fb-field{display:flex; flex-direction:column; gap:4px;}
|
||||
.fb-field label{font-size:11.5px; font-weight:600; color:var(--muted);}
|
||||
.fb-field select{
|
||||
border:1px solid var(--line); border-radius:9px; background:#fff;
|
||||
padding:8px 12px; min-width:170px; outline:none; font-size:13px;
|
||||
}
|
||||
.fb-field select:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.fb-field select:disabled{background:#f8fafc; color:#94a3b8; cursor:not-allowed;}
|
||||
/* باکس تایپی متریال — دقیقاً همشکل باکسهای انتخاب و دکمه مرتبسازی */
|
||||
.fb-field input[list], #filt-mat-input{
|
||||
border:1px solid var(--line); border-radius:9px; background:#fff;
|
||||
padding:8px 12px; min-width:170px; outline:none; font-size:13px;
|
||||
}
|
||||
.fb-field input[list]:focus, #filt-mat-input:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.sort-btn{
|
||||
display:inline-flex; align-items:center; gap:7px; border:1px solid var(--line);
|
||||
background:#fff; padding:8px 14px; border-radius:9px; font-size:12.5px; font-weight:500;
|
||||
color:var(--ink); transition:all .15s;
|
||||
}
|
||||
.sort-btn:hover{border-color:#c7d2fe; background:var(--accent-bg);}
|
||||
.sort-btn.active{background:var(--accent); color:#fff; border-color:var(--accent);}
|
||||
.sort-btn svg{width:15px; height:15px;}
|
||||
.clear-btn{
|
||||
border:none; background:transparent; color:var(--red); font-size:12px; font-weight:600;
|
||||
padding:8px 6px; display:inline-flex; align-items:center; gap:5px;
|
||||
}
|
||||
.clear-btn:hover{text-decoration:underline;}
|
||||
.clear-btn svg{width:13px; height:13px;}
|
||||
.count-chip{
|
||||
margin-inline-start:auto; background:var(--slate-bg); color:var(--muted);
|
||||
font-size:12px; font-weight:600; padding:6px 14px; border-radius:999px;
|
||||
}
|
||||
|
||||
/* ---------- inventory table ---------- */
|
||||
.tbl-wrap{overflow-x:auto;}
|
||||
table.inv{width:100%; border-collapse:collapse; min-width:900px;}
|
||||
table.inv th{
|
||||
font-size:12px; font-weight:700; color:var(--muted); text-align:right;
|
||||
padding:10px 14px; background:#f8fafc; border-bottom:1px solid var(--line); white-space:nowrap;
|
||||
}
|
||||
table.inv td{padding:11px 14px; border-bottom:1px solid var(--line); font-size:13px; vertical-align:middle;}
|
||||
table.inv tr.data-row{cursor:pointer; transition:background .12s;}
|
||||
table.inv tr.data-row:hover{background:#f8faff;}
|
||||
table.inv tr.data-row.expanded{background:var(--accent-bg);}
|
||||
table.inv tr.data-row.expanded td{border-bottom-color:#c7d2fe;}
|
||||
tr.tl-row td{background:#fbfcfe; padding:14px 18px; border-bottom:1px solid var(--line);}
|
||||
.mat-cell{font-weight:700;}
|
||||
.spec-chip{
|
||||
display:inline-block; background:var(--slate-bg); border:1px solid var(--line);
|
||||
color:var(--ink); font-size:10.5px; padding:1px 8px; border-radius:5px; margin-inline-start:4px; font-weight:500;
|
||||
}
|
||||
.caret{width:18px; height:18px; color:#94a3b8; transition:transform .15s;}
|
||||
tr.expanded .caret{transform:rotate(90deg); color:var(--accent);}
|
||||
.qty-cell{font-weight:800; font-size:14px; white-space:nowrap;}
|
||||
.unit-cell{color:var(--muted); font-size:12px;}
|
||||
.date-cell{color:var(--muted); font-size:12px; white-space:nowrap;}
|
||||
.wh-cell small{display:block; font-size:10.5px; color:var(--muted);}
|
||||
.row-ico{width:16px; height:16px; color:#94a3b8;}
|
||||
.empty-box{padding:34px; text-align:center; color:var(--muted); font-size:13px;}
|
||||
|
||||
/* ---------- timeline ---------- */
|
||||
.tl-head{display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:8px;}
|
||||
.tl-title{font-size:13px; font-weight:700; display:flex; align-items:center; gap:7px;}
|
||||
.tl-title svg{width:15px; height:15px; color:var(--accent);}
|
||||
.tl-hint{font-size:11px; color:var(--muted);}
|
||||
.tl-track{
|
||||
display:flex; gap:24px; overflow-x:auto; padding:12px 4px 14px;
|
||||
direction:rtl; align-items:stretch;
|
||||
}
|
||||
.tl-box{
|
||||
position:relative; min-width:118px; max-width:118px; flex:0 0 auto;
|
||||
background:#fff; border:2px solid var(--line); border-radius:0;
|
||||
padding:8px 10px 9px; cursor:pointer; transition:all .12s; text-align:center;
|
||||
}
|
||||
.tl-box::before{
|
||||
content:""; position:absolute; top:-2px; right:-2px; left:-2px; height:3px;
|
||||
background:var(--boxc,#94a3b8);
|
||||
}
|
||||
.tl-box::after{
|
||||
content:"←"; position:absolute; left:-23px; top:50%; transform:translateY(-50%);
|
||||
color:#94a3b8; font-size:13px; font-weight:700; width:16px; text-align:center;
|
||||
}
|
||||
.tl-box:last-child::after{content:none;}
|
||||
.tl-box:hover{transform:translateY(-3px); box-shadow:0 6px 14px rgba(15,23,42,.12);}
|
||||
.tl-box.sel{border-color:var(--boxc,#94a3b8); box-shadow:0 0 0 3px rgba(79,70,229,.18);}
|
||||
.tl-ico{width:20px; height:20px; margin:0 auto 4px; color:var(--boxc,#64748b);}
|
||||
.tl-type{font-size:11px; font-weight:700; color:var(--ink); white-space:nowrap;}
|
||||
.tl-qty{font-size:13px; font-weight:800; margin-top:1px; white-space:nowrap; direction:ltr; color:var(--boxc,#0f172a);}
|
||||
.tl-bal{font-size:11px; font-weight:700; margin-top:3px; white-space:nowrap; color:var(--ink);}
|
||||
.tl-bal b{color:var(--accent-dark,#4338ca);}
|
||||
.tl-scroll-hint{font-size:10.5px; color:#94a3b8; text-align:left; margin-top:-6px;}
|
||||
.brand-break{display:flex; gap:8px; flex-wrap:wrap; margin:2px 0 10px;}
|
||||
.brand-btn{
|
||||
display:inline-flex; align-items:center; gap:5px; border:1px solid var(--line);
|
||||
background:#fff; border-radius:9px; padding:6px 12px; font-size:12px; color:var(--ink);
|
||||
cursor:pointer; transition:all .12s;
|
||||
}
|
||||
.brand-btn:hover{border-color:#c7d2fe; background:var(--accent-bg);}
|
||||
.brand-btn b{font-weight:800; color:var(--accent-dark,#4338ca);}
|
||||
.brand-btn.act{background:var(--accent); border-color:var(--accent); color:#fff;}
|
||||
.brand-btn.act b{color:#fff;}
|
||||
.brand-btn .b-close{font-size:10px; opacity:.85;}
|
||||
.tl-sub{
|
||||
display:flex; align-items:center; gap:6px; font-size:11.5px; font-weight:700; color:var(--muted);
|
||||
margin:8px 0 2px;
|
||||
}
|
||||
.tl-sub svg{width:13px; height:13px;}
|
||||
.tl-sub b{color:var(--ink);}
|
||||
|
||||
/* ---------- tx detail (جمعوجور — فقط اطلاعات جدید) ---------- */
|
||||
.txdetail-min{
|
||||
background:#fff; border:1px solid var(--line); border-right:3px solid var(--boxc,#64748b);
|
||||
border-radius:8px; padding:9px 14px; margin-top:6px;
|
||||
display:flex; align-items:center; gap:8px 22px; flex-wrap:wrap; font-size:12px;
|
||||
}
|
||||
.txdetail-min .d-min{display:inline-flex; align-items:baseline; gap:6px;}
|
||||
.txdetail-min .d-min .k{color:var(--muted); font-size:11px;}
|
||||
.txdetail-min .d-min b{font-weight:600; color:var(--ink);}
|
||||
.diff-row{
|
||||
display:flex; align-items:center; gap:10px; margin-top:12px; padding-top:10px;
|
||||
border-top:1px dashed var(--line); flex-wrap:wrap;
|
||||
}
|
||||
.diff-pill{display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:700; padding:4px 12px; border-radius:6px;}
|
||||
.diff-pill.up{background:var(--green-bg); color:var(--green);}
|
||||
.diff-pill.down{background:var(--blue-bg); color:var(--blue);}
|
||||
.diff-pill.same{background:var(--slate-bg); color:var(--muted);}
|
||||
|
||||
/* ---------- change-inventory page (تغییر موجودی) ---------- */
|
||||
.txp-layout{display:grid; grid-template-columns:240px 1fr; gap:16px; align-items:start;}
|
||||
@media (max-width:860px){.txp-layout{grid-template-columns:1fr;}}
|
||||
.tx-types{
|
||||
background:#fff; border:1px solid var(--line); border-radius:14px; padding:10px;
|
||||
display:flex; flex-direction:column; gap:6px;
|
||||
position:sticky; top:14px; box-shadow:0 1px 2px rgba(15,23,42,.04);
|
||||
}
|
||||
.tx-types h3{
|
||||
font-size:11.5px; font-weight:800; color:var(--muted); padding:6px 12px 4px;
|
||||
display:flex; align-items:center; gap:7px;
|
||||
}
|
||||
.tx-types h3 svg{width:13px; height:13px;}
|
||||
.tx-type-btn{
|
||||
position:relative; display:flex; align-items:center; gap:10px;
|
||||
border:1px solid var(--line); background:#fff; border-radius:10px;
|
||||
padding:10px 12px; font-size:12.5px; font-weight:600; color:var(--ink);
|
||||
transition:all .15s; text-align:right; width:100%;
|
||||
}
|
||||
.tx-type-btn svg{width:16px; height:16px; color:var(--tc,#64748b); transition:color .15s;}
|
||||
.tx-type-btn .dir{
|
||||
font-size:9.5px; font-weight:800; margin-inline-start:auto;
|
||||
background:var(--tc-bg,#f1f5f9); color:var(--tc,#64748b);
|
||||
border-radius:999px; padding:2px 9px; letter-spacing:.3px; white-space:nowrap;
|
||||
}
|
||||
.tx-type-btn:hover{border-color:var(--tc,#94a3b8); background:var(--tc-bg,#f8fafc); transform:translateY(-1px);}
|
||||
.tx-type-btn.active{
|
||||
background:#fff; border-color:var(--tc,#4f46e5);
|
||||
box-shadow:0 0 0 1px var(--tc,#4f46e5), 0 4px 10px rgba(79,70,229,.12);
|
||||
}
|
||||
.tx-type-btn.active::before{
|
||||
content:""; position:absolute; inset-inline-start:-1px; top:9px; bottom:9px; width:3px;
|
||||
border-radius:3px; background:var(--tc,#4f46e5);
|
||||
}
|
||||
.tx-type-btn.active svg{color:var(--tc,#4f46e5);}
|
||||
.tx-type-btn.active .dir{background:var(--tc,#4f46e5); color:#fff;}
|
||||
.tx-form-card{
|
||||
background:#fff; border:1px solid var(--line); border-radius:14px;
|
||||
padding:20px 22px; box-shadow:0 1px 2px rgba(15,23,42,.04);
|
||||
}
|
||||
.tx-form-card h2{
|
||||
font-size:15.5px; font-weight:800; display:flex; align-items:center; gap:10px;
|
||||
padding-bottom:13px; border-bottom:1px solid var(--line); margin-bottom:6px;
|
||||
}
|
||||
.tx-form-card h2 svg{
|
||||
width:16px; height:16px; color:var(--tc,#4f46e5);
|
||||
background:var(--tc-bg,#eef2ff); padding:6px; border-radius:9px;
|
||||
box-sizing:content-box;
|
||||
}
|
||||
.tx-desc{font-size:12px; color:var(--muted); margin-bottom:18px; line-height:1.9;}
|
||||
.f-grid{display:grid; grid-template-columns:1fr 1fr; gap:14px 22px;}
|
||||
@media (max-width:760px){.f-grid{grid-template-columns:1fr;}}
|
||||
.f-grid .f-row{margin-bottom:0;}
|
||||
.f-row{margin-bottom:14px;}
|
||||
.f-row label{
|
||||
display:flex; align-items:center; gap:5px; flex-wrap:wrap;
|
||||
font-size:12px; font-weight:700; color:var(--ink); margin-bottom:6px;
|
||||
}
|
||||
.f-row label .req{color:var(--red);}
|
||||
.f-row label .lbl-hint{font-size:10.5px; color:var(--muted); font-weight:500;}
|
||||
.f-row select,.f-row input[type=text],.f-row input[type=number],.f-row input[type=datetime-local],.f-row textarea{
|
||||
width:100%; border:1px solid var(--line); border-radius:10px; padding:9px 13px;
|
||||
outline:none; background:#fff; font-size:13px; color:var(--ink);
|
||||
transition:border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.f-row select:focus,.f-row input:focus,.f-row textarea:focus{
|
||||
border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);
|
||||
}
|
||||
.f-row textarea{min-height:72px; resize:vertical; line-height:1.8;}
|
||||
.f-row input.ltr{direction:ltr; text-align:left;}
|
||||
.f-help{font-size:11px; color:var(--muted); margin-top:4px; line-height:1.7;}
|
||||
.f-help.warn{color:var(--red); font-weight:600;}
|
||||
.f-help.ok{color:var(--green); font-weight:600;}
|
||||
.fromto{display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:2px;}
|
||||
.fromto .box{
|
||||
border:1px solid var(--line); border-radius:12px; padding:12px 14px; background:#fbfcfe;
|
||||
transition:border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.fromto .box:focus-within{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.fromto .box .lbl{font-size:11px; font-weight:800; color:var(--tc,#4f46e5); margin-bottom:7px; display:flex; align-items:center; gap:6px;}
|
||||
.fromto .box .lbl svg{width:13px; height:13px;}
|
||||
.fromto .box select{
|
||||
width:100%; border:1px solid var(--line); border-radius:9px; padding:8px 11px;
|
||||
font-size:13px; outline:none; background:#fff; transition:border-color .15s;
|
||||
}
|
||||
.fromto .box select:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.spec-box{
|
||||
border:1px solid var(--line); border-radius:12px; background:#fbfcfe;
|
||||
padding:13px 13px 11px; margin-top:2px;
|
||||
}
|
||||
.spec-rows{margin-bottom:10px;}
|
||||
.spec-row{display:flex; gap:8px; margin-bottom:8px; align-items:center;}
|
||||
.spec-row input{
|
||||
border:1px solid var(--line); border-radius:9px; padding:8px 12px; font-size:13px;
|
||||
outline:none; background:#fff; transition:border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.spec-row input:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.spec-row input.k{flex:0 0 30%;}
|
||||
.spec-row input.v{flex:1;}
|
||||
.spec-del{
|
||||
flex:none; width:32px; height:32px; border-radius:9px; border:1px solid var(--line);
|
||||
background:#fff; color:var(--red); display:flex; align-items:center; justify-content:center;
|
||||
transition:all .15s;
|
||||
}
|
||||
.spec-del:hover{background:var(--red-bg); border-color:#fca5a5;}
|
||||
.spec-del svg{width:13px; height:13px;}
|
||||
.add-spec{
|
||||
display:inline-flex; align-items:center; gap:6px; border:1.5px dashed var(--accent);
|
||||
color:var(--accent-dark); background:var(--accent-bg); font-size:12px; font-weight:700;
|
||||
padding:7px 14px; border-radius:9px; transition:all .15s;
|
||||
}
|
||||
.add-spec:hover{background:#e0e7ff; border-style:solid;}
|
||||
.add-spec svg{width:13px; height:13px;}
|
||||
.pcat-wrap{margin-top:14px;}
|
||||
.pcat-wrap .f-row{margin-bottom:0;}
|
||||
|
||||
/* ---------- records table (list ops) ---------- */
|
||||
.rows-tbl{
|
||||
width:100%; border-collapse:separate; border-spacing:0;
|
||||
border:1px solid var(--line); border-radius:12px; overflow:hidden;
|
||||
}
|
||||
.rows-tbl th{
|
||||
font-size:11.5px; font-weight:800; color:var(--muted); text-align:right;
|
||||
padding:10px 12px; background:#f8fafc; border-bottom:1px solid var(--line); white-space:nowrap;
|
||||
}
|
||||
.rows-tbl td{padding:10px 12px; border-bottom:1px solid var(--line); font-size:12.5px; vertical-align:middle;}
|
||||
.rows-tbl tbody tr:last-child td{border-bottom:none;}
|
||||
.rows-tbl tbody tr:hover{background:#f8faff;}
|
||||
.rows-tbl td .cur{font-weight:800;}
|
||||
.rows-tbl input.qty{
|
||||
width:92px; border:1px solid var(--line); border-radius:8px; padding:6px 10px;
|
||||
font-size:12.5px; direction:ltr; text-align:center; outline:none; background:#fff;
|
||||
transition:border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.rows-tbl input.qty:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.rows-tbl input.note{
|
||||
width:100%; min-width:130px; border:1px solid var(--line); border-radius:8px;
|
||||
padding:6px 10px; font-size:12px; outline:none; background:#fff;
|
||||
transition:border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.rows-tbl input.note:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.diff-live{font-size:11.5px; font-weight:800; white-space:nowrap;}
|
||||
.diff-live.up{color:var(--green);}
|
||||
.diff-live.down{color:var(--blue);}
|
||||
.diff-live.same{color:var(--muted);}
|
||||
.rows-empty{padding:30px 20px; text-align:center; color:var(--muted); font-size:12.5px; line-height:1.9;}
|
||||
.hint-box{
|
||||
background:#f8fafc; border:1px solid var(--line); border-radius:10px;
|
||||
padding:10px 13px; font-size:12px; color:var(--muted); margin-bottom:14px;
|
||||
display:flex; align-items:flex-start; gap:8px; line-height:1.8;
|
||||
}
|
||||
.hint-box b{color:var(--ink);}
|
||||
.hint-box.alert{background:#fff5f5; border-color:#fecaca; color:var(--red);}
|
||||
.hint-box.alert b{color:var(--red);}
|
||||
.hint-box svg{width:14px; height:14px; flex:none; margin-top:4px;}
|
||||
.submit-bar{
|
||||
margin-top:18px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;
|
||||
background:#f8fafc; border:1px solid var(--line); border-radius:12px; padding:12px 16px;
|
||||
}
|
||||
.submit-bar .actor{font-size:12px; color:var(--muted); display:flex; align-items:center; gap:7px;}
|
||||
.submit-bar .actor svg{width:14px; height:14px;}
|
||||
.submit-bar .actor b{color:var(--ink); font-weight:700;}
|
||||
.submit-btn{
|
||||
margin-inline-start:auto; background:var(--tc,#4f46e5); color:#fff; border:none; font-size:13.5px;
|
||||
font-weight:700; padding:11px 28px; border-radius:10px; display:flex; align-items:center; gap:8px;
|
||||
box-shadow:0 2px 8px rgba(79,70,229,.25);
|
||||
transition:opacity .15s, transform .15s, box-shadow .15s;
|
||||
}
|
||||
.submit-btn:hover{opacity:.92; transform:translateY(-1px);}
|
||||
.submit-btn:disabled{background:#cbd5e1; cursor:not-allowed; box-shadow:none; transform:none;}
|
||||
.submit-btn svg{width:16px; height:16px;}
|
||||
.readonly-block{
|
||||
max-width:520px; margin:60px auto; background:#fff; border:1px solid var(--line);
|
||||
border-radius:14px; padding:34px; text-align:center;
|
||||
}
|
||||
.readonly-block .big-ico{
|
||||
width:52px; height:52px; margin:0 auto 14px; color:#f59e0b; background:#fffbeb;
|
||||
border:1px solid #fde68a; border-radius:14px; display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.readonly-block .big-ico svg{width:26px; height:26px;}
|
||||
.readonly-block h3{font-size:16px; font-weight:800; margin-bottom:6px;}
|
||||
.readonly-block p{font-size:13px; color:var(--muted);}
|
||||
|
||||
/* ---------- calendar (کنترل روزانه) ---------- */
|
||||
.cal-layout{display:grid; grid-template-columns:390px 1fr; gap:16px; align-items:start;}
|
||||
@media (max-width:960px){.cal-layout{grid-template-columns:1fr;}}
|
||||
.cal-card{background:#fff; border:1px solid var(--line); border-radius:12px; padding:16px;}
|
||||
.cal-head{display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;}
|
||||
.cal-head .month{font-size:14.5px; font-weight:800; display:flex; align-items:center; gap:8px;}
|
||||
.cal-head .month svg{width:16px; height:16px; color:var(--accent);}
|
||||
.cal-nav{display:flex; gap:6px; direction:ltr;}
|
||||
.cal-nav button{
|
||||
width:30px; height:30px; border-radius:8px; border:1px solid var(--line); background:#fff;
|
||||
color:var(--ink); display:flex; align-items:center; justify-content:center; font-size:15px; font-weight:700;
|
||||
}
|
||||
.cal-nav button:hover{border-color:#c7d2fe; background:var(--accent-bg);}
|
||||
.cal-grid{display:grid; grid-template-columns:repeat(7,1fr); gap:4px;}
|
||||
.cal-dow{font-size:11px; font-weight:700; color:var(--muted); text-align:center; padding:4px 0;}
|
||||
.day-cell{
|
||||
position:relative; aspect-ratio:1/1.15; border:1px solid var(--line); border-radius:8px;
|
||||
background:#fff; font-size:12.5px; font-weight:600; color:var(--ink);
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px;
|
||||
transition:all .12s; min-height:52px;
|
||||
}
|
||||
.day-cell:hover{border-color:#c7d2fe; background:var(--accent-bg);}
|
||||
.day-cell.out{color:#cbd5e1; background:#fafbfd;}
|
||||
.day-cell.today{border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-bg);}
|
||||
.day-cell.sel{background:var(--accent); border-color:var(--accent); color:#fff;}
|
||||
.day-cell.sel .d-dot{background:#fff; color:var(--accent);}
|
||||
.d-dot{
|
||||
min-width:17px; height:17px; padding:0 4px; border-radius:999px; background:var(--green);
|
||||
color:#fff; font-size:9.5px; font-weight:800; display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.day-cell.out .d-dot{opacity:.4;}
|
||||
.day-panel{background:#fff; border:1px solid var(--line); border-radius:12px; overflow:hidden;}
|
||||
.day-panel .dp-head{
|
||||
padding:13px 18px; border-bottom:1px solid var(--line);
|
||||
display:flex; align-items:center; gap:12px; flex-wrap:wrap;
|
||||
}
|
||||
.day-panel .dp-head h2{font-size:14.5px; font-weight:800; display:flex; align-items:center; gap:8px;}
|
||||
.day-panel .dp-head h2 svg{width:16px; height:16px; color:var(--accent);}
|
||||
.dp-chips{display:flex; gap:8px; margin-inline-start:auto; flex-wrap:wrap;}
|
||||
.dp-chip{font-size:11px; font-weight:700; padding:4px 12px; border-radius:999px;}
|
||||
.dp-chip.in{background:var(--green-bg); color:var(--green);}
|
||||
.dp-chip.out{background:var(--blue-bg); color:var(--blue);}
|
||||
.dp-list{max-height:560px; overflow-y:auto;}
|
||||
.dp-item{border-bottom:1px solid var(--line); font-size:12.5px;}
|
||||
.dp-item.act{background:#f8faff;}
|
||||
.dp-hit{
|
||||
display:flex; align-items:center; gap:12px; padding:11px 18px; width:100%;
|
||||
background:none; border:none; text-align:right; color:inherit; font:inherit; cursor:pointer;
|
||||
}
|
||||
.dp-hit:hover{background:#f8faff;}
|
||||
.dp-item .badge{
|
||||
flex:none; width:34px; height:34px; border-radius:9px; display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.dp-item .badge svg{width:16px; height:16px;}
|
||||
.dp-item .main{flex:1; min-width:0;}
|
||||
.dp-item .main .t{font-weight:700;}
|
||||
.dp-item .main .s{font-size:11px; color:var(--muted); margin-top:1px;}
|
||||
.dp-item .qty{font-weight:800; white-space:nowrap;}
|
||||
.dp-item .qty.in{color:var(--green);}
|
||||
.dp-item .qty.out{color:var(--blue);}
|
||||
.dp-item .time{color:var(--muted); font-size:11px; white-space:nowrap;}
|
||||
.dp-actions{display:flex; gap:8px; padding:0 18px 12px;}
|
||||
.dp-actions button{
|
||||
flex:1; display:inline-flex; align-items:center; justify-content:center; gap:6px;
|
||||
border:1px solid var(--line); border-radius:8px; padding:8px 10px; font-size:12px; font-weight:700;
|
||||
background:#fff; cursor:pointer;
|
||||
}
|
||||
.dp-actions button:hover{opacity:.85;}
|
||||
.dp-actions button svg{width:13px; height:13px;}
|
||||
.dp-actions .act-ret{color:var(--green); border-color:#bbf7d0; background:var(--green-bg);}
|
||||
.dp-actions .act-out{color:var(--blue); border-color:#bfdbfe; background:var(--blue-bg);}
|
||||
.dp-actions-noact{padding:0 18px 12px; font-size:11px; color:var(--muted);}
|
||||
.dp-hint{padding:9px 18px; font-size:11.5px; color:var(--muted); background:#fbfcfe; border-bottom:1px solid var(--line);}
|
||||
|
||||
/* ---------- warehouses ---------- */
|
||||
.wh-add{margin-bottom:14px;}
|
||||
.wh-add h3{font-size:13.5px; font-weight:700; display:flex; align-items:center; gap:7px; margin-bottom:10px;}
|
||||
.wh-add h3 svg{width:15px; height:15px; color:var(--accent);}
|
||||
.wh-add-row{display:flex; gap:10px; flex-wrap:wrap; align-items:center;}
|
||||
.wh-add-row input,.wh-add-row select{
|
||||
border:1px solid var(--line); border-radius:9px; background:#fff; padding:8px 12px;
|
||||
outline:none; font-size:13px; min-width:180px;
|
||||
}
|
||||
.wh-add-row input:focus,.wh-add-row select:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
|
||||
/* ---------- org view (سازماندهی) ---------- */
|
||||
.org-grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(360px,1fr)); gap:16px;}
|
||||
@media (max-width:900px){.org-grid{grid-template-columns:1fr;}}
|
||||
.org-card{
|
||||
background:#fff; border:1px solid var(--line); border-radius:14px; overflow:hidden;
|
||||
box-shadow:0 1px 2px rgba(15,23,42,.04);
|
||||
}
|
||||
.org-head{
|
||||
display:flex; align-items:center; gap:9px; padding:14px 16px;
|
||||
background:linear-gradient(180deg,#f8faff,#fff); border-bottom:1px solid var(--line);
|
||||
}
|
||||
.org-head svg{width:17px; height:17px; color:var(--accent);}
|
||||
.org-head h3{font-size:14px; font-weight:800; flex:1;}
|
||||
.org-badge{
|
||||
font-size:10.5px; font-weight:700; color:var(--accent-dark);
|
||||
background:var(--accent-bg); border:1px solid #e0e7ff;
|
||||
border-radius:999px; padding:3px 10px; white-space:nowrap;
|
||||
}
|
||||
.org-sec{padding:13px 16px 15px; border-bottom:1px solid var(--line);}
|
||||
.org-sec:last-child{border-bottom:none;}
|
||||
.org-sec h4{
|
||||
font-size:11.5px; font-weight:800; color:var(--muted); margin-bottom:9px;
|
||||
display:flex; align-items:center; gap:6px;
|
||||
}
|
||||
.org-sec h4 svg{width:13px; height:13px;}
|
||||
.org-chip{
|
||||
display:flex; align-items:center; gap:8px; border:1px solid var(--line);
|
||||
border-radius:10px; padding:7px 11px; margin-bottom:7px; background:#fbfcfe;
|
||||
}
|
||||
.org-chip svg{width:14px; height:14px; color:var(--green);}
|
||||
.org-chip .nm{font-weight:700; font-size:12.5px;}
|
||||
.org-chip .pw{font-size:10.5px; color:var(--muted); background:#fff; border:1px solid var(--line);
|
||||
border-radius:6px; padding:1px 7px;}
|
||||
.org-chip.org-edit{flex-wrap:wrap; background:#fff;}
|
||||
.org-chip.org-edit input{
|
||||
flex:1; min-width:110px; border:1px solid var(--line); border-radius:8px;
|
||||
padding:6px 10px; font-size:12px; outline:none; background:#fff;
|
||||
}
|
||||
.org-chip.org-edit input:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.org-chip.org-edit select{
|
||||
border:1px solid var(--line); border-radius:8px; padding:5px 8px; font-size:11.5px;
|
||||
outline:none; background:#fff;
|
||||
}
|
||||
.org-chip.org-edit select:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.org-del{
|
||||
margin-inline-start:auto; width:26px; height:26px; border-radius:8px; border:1px solid var(--line);
|
||||
background:#fff; color:var(--red); display:flex; align-items:center; justify-content:center;
|
||||
transition:all .15s;
|
||||
}
|
||||
.org-del:hover{background:var(--red-bg); border-color:#fca5a5;}
|
||||
.org-del svg{width:12px; height:12px;}
|
||||
.org-wh{
|
||||
display:flex; align-items:center; gap:10px; border:1px solid var(--line);
|
||||
border-radius:10px; padding:8px 11px; margin-bottom:7px; background:#fbfcfe;
|
||||
}
|
||||
.org-wh .info{flex:1; min-width:0;}
|
||||
.org-wh .info b{font-size:12.5px; display:block;}
|
||||
.org-wh .info small{font-size:10.5px; color:var(--muted); display:block; overflow:hidden;
|
||||
text-overflow:ellipsis; white-space:nowrap;}
|
||||
.org-wh select{
|
||||
border:1px solid var(--line); border-radius:8px; padding:5px 8px; font-size:11.5px;
|
||||
outline:none; background:#fff; max-width:170px;
|
||||
}
|
||||
.org-wh select:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.org-none{font-size:11.5px; color:var(--muted); background:var(--slate-bg); border-radius:8px;
|
||||
padding:8px 12px; margin-bottom:7px;}
|
||||
.org-add{display:flex; gap:8px; flex-wrap:wrap;}
|
||||
.org-add input{
|
||||
flex:1; min-width:120px; border:1px solid var(--line); border-radius:9px;
|
||||
padding:7px 11px; font-size:12.5px; outline:none; background:#fff;
|
||||
}
|
||||
.org-add input:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.wh-grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:14px;}
|
||||
.wh-card{background:#fff; border:1px solid var(--line); border-radius:12px; padding:16px;}
|
||||
.wh-card h3{font-size:14px; font-weight:700; display:flex; align-items:center; gap:8px; margin-bottom:4px;}
|
||||
.wh-card h3 svg{width:16px; height:16px; color:var(--accent);}
|
||||
.wh-card .desc{font-size:12px; color:var(--muted); min-height:20px;}
|
||||
.wh-meta{display:flex; gap:14px; flex-wrap:wrap; margin-top:10px; font-size:11.5px; color:var(--muted);}
|
||||
.wh-meta b{color:var(--ink);}
|
||||
.wh-actions{display:flex; gap:8px; margin-top:12px; padding-top:12px; border-top:1px solid var(--line);}
|
||||
.wh-actions button{
|
||||
display:inline-flex; align-items:center; gap:5px; font-size:12px; font-weight:600;
|
||||
border:1px solid var(--line); background:#fff; border-radius:8px; padding:5px 12px; color:var(--ink);
|
||||
}
|
||||
.wh-actions button:hover{border-color:#c7d2fe; background:var(--accent-bg);}
|
||||
.wh-actions button svg{width:13px; height:13px;}
|
||||
.wh-edit{border-top:1px dashed var(--line); margin-top:12px; padding-top:12px;}
|
||||
.wh-edit input{
|
||||
width:100%; border:1px solid var(--line); border-radius:9px; padding:7px 12px; font-size:12.5px;
|
||||
margin-bottom:8px; outline:none;
|
||||
}
|
||||
.wh-edit input:focus{border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.mini-btn{
|
||||
border:1px solid var(--accent); background:var(--accent); color:#fff; font-size:12px; font-weight:600;
|
||||
padding:7px 14px; border-radius:8px; white-space:nowrap;
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
}
|
||||
.mini-btn:hover{background:var(--accent-dark);}
|
||||
.mini-btn svg{width:13px; height:13px;}
|
||||
|
||||
/* ---------- toast ---------- */
|
||||
#toasts{position:fixed; top:14px; left:50%; transform:translateX(-50%); z-index:999; display:flex; flex-direction:column; gap:8px; width:min(92vw,430px);}
|
||||
.toast{
|
||||
background:#0f172a; color:#fff; font-size:12.5px; font-weight:500; border-radius:10px;
|
||||
padding:11px 16px; display:flex; align-items:center; gap:9px;
|
||||
box-shadow:0 10px 26px rgba(15,23,42,.28); animation:tin .25s ease;
|
||||
}
|
||||
.toast svg{width:15px; height:15px; flex:none;}
|
||||
.toast.ok svg{color:#4ade80;}
|
||||
.toast.err{background:#7f1d1d;}
|
||||
.toast.err svg{color:#fca5a5;}
|
||||
.toast.info svg{color:#93c5fd;}
|
||||
.toast.hide{opacity:0; transition:opacity .3s;}
|
||||
@keyframes tin{from{opacity:0; transform:translateY(-8px);} to{opacity:1; transform:translateY(0);}}
|
||||
.footer-note{margin-top:22px; text-align:center; font-size:11px; color:#94a3b8;}
|
||||
/* nav buttons as links */
|
||||
a.nav-btn{display:inline-flex; text-decoration:none;}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,109 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
const core = readFileSync('C:/Users/Administrator/Downloads/v1.2/core.js', 'utf8');
|
||||
const invHtml = readFileSync('C:/Users/Administrator/Downloads/v1.2/inventory-system.html', 'utf8');
|
||||
const chgHtml = readFileSync('C:/Users/Administrator/Downloads/v1.2/change-inventory.html', 'utf8');
|
||||
const m1 = invHtml.match(/<script>([\s\S]*?)<\/script>/);
|
||||
const m2 = chgHtml.match(/<script>([\s\S]*?)<\/script>/);
|
||||
if (!m1 || !m2) { console.error('NO SCRIPT FOUND'); process.exit(1); }
|
||||
|
||||
const stubs = `
|
||||
var lastHTML='';
|
||||
var dummyEl={_h:'', get innerHTML(){return this._h;}, set innerHTML(v){this._h=v; lastHTML=v;},
|
||||
textContent:'',value:'',className:'',disabled:false,style:{},title:'',
|
||||
classList:{add:function(){},remove:function(){}},appendChild:function(){},
|
||||
focus:function(){},setSelectionRange:function(){},remove:function(){}};
|
||||
var document={getElementById:function(){return dummyEl;},querySelectorAll:function(){return [];},createElement:function(){return dummyEl;}};
|
||||
var localStorage={_d:{},getItem:function(k){return this._d[k]||null;},setItem:function(k,v){this._d[k]=String(v);},removeItem:function(k){delete this._d[k];}};
|
||||
var confirm=function(){return true;};
|
||||
var window=globalThis;
|
||||
var location={search:'',href:'',hash:''};
|
||||
var sessionStorage={_d:{},getItem:function(k){return this._d[k]||null;},setItem:function(k,v){this._d[k]=String(v);},removeItem:function(k){delete this._d[k];}};
|
||||
window.APP_CONFIG={apiBase:'http://localhost:8000/api/v1'};
|
||||
`;
|
||||
|
||||
const test = `
|
||||
var assert=function(c,msg){ if(!c){ console.error('FAIL: '+msg); process.exit(1);} };
|
||||
|
||||
/* --- هویت: کاربران پیشفرض با ایمیل --- */
|
||||
assert(SEED_USERS.length===4,'seed users =4');
|
||||
assert(SEED_USERS[1].email==='mousavi@sample.ir','seed users have emails');
|
||||
assert(state.users.length===4,'loadUsers seeds 4 users');
|
||||
|
||||
/* --- نشست Casdoor: نگاشت با ایمیل --- */
|
||||
session={mode:'casdoor', email:'MOUSAVI@sample.ir', name:'آقای موسوی', sub:'x', token:'t'};
|
||||
var u1=user();
|
||||
assert(u1&&u1.pw==='1'&&u1.role==='keeper','casdoor session maps to local user (case-insensitive)');
|
||||
session={mode:'casdoor', email:'ghayeb@sample.ir'};
|
||||
assert(user()===null,'unknown email has no local profile');
|
||||
session=null;
|
||||
localStorage._d['inventory-session-v1']=JSON.stringify({mode:'casdoor', email:'goli@sample.ir'});
|
||||
assert(loadSession()!==null,'loadSession accepts valid casdoor session');
|
||||
localStorage._d['inventory-session-v1']=JSON.stringify({mode:'casdoor', email:'nobody@x.ir'});
|
||||
assert(loadSession()===null,'loadSession rejects unknown casdoor email');
|
||||
delete localStorage._d['inventory-session-v1'];
|
||||
|
||||
/* --- ورود با Casdoor: ساخت URL --- */
|
||||
location.href='';
|
||||
window.CASDOOR_CONFIG={serverUrl:'http://localhost:8080', clientId:'inventory-app-client',
|
||||
clientSecret:'s', organization:'inventory', appName:'inventory-app', redirectUri:'http://localhost:8123/callback.html'};
|
||||
startCasdoorLogin();
|
||||
setTimeout(function(){
|
||||
try{
|
||||
assert(location.href.indexOf('http://localhost:8080/login/oauth/authorize')===0,'login URL targets authorize');
|
||||
assert(location.href.indexOf('code_challenge=')>=0&&location.href.indexOf('code_challenge_method=S256')>=0,'login URL has PKCE');
|
||||
var stored=JSON.parse(sessionStorage.getItem('casdoor-pkce'));
|
||||
assert(stored&&stored.verifier&&stored.state,'pkce verifier stored');
|
||||
console.log('SYNC_OK');
|
||||
/* --- تستهای API (در برابر استک واقعی) --- */
|
||||
session={pw:'1'};
|
||||
loadAll().then(function(ok){
|
||||
assert(ok,'PM loadAll ok');
|
||||
assert(state.projects.length>=2,'projects loaded from API: '+state.projects.length);
|
||||
assert(state.warehouses.length>=2,'warehouses loaded');
|
||||
assert(state.inventory.length>=1,'inventory loaded: '+state.inventory.length);
|
||||
assert(state.transactions.length>=1,'transactions loaded');
|
||||
var u=user();
|
||||
assert(u.projectId&&state.projects.some(function(p){return String(p.id)===String(u.projectId);}),'PM project resolved to UUID');
|
||||
var h=authHeaders();
|
||||
assert(h['X-User-Role']==='pm','pm role header');
|
||||
assert(h['X-Project-Id']===u.projectId,'pm project header is UUID');
|
||||
assert(/^[A-Za-z0-9+/=]+$/.test(h['X-User-Name-B64']||''),'name header is base64 ascii');
|
||||
var gs=visibleGroups();
|
||||
assert(gs.length>=1,'dashboard groups built from API rows: '+gs.length);
|
||||
var g=gs[0];
|
||||
assert(g.qty>0&&g.recs.length>=1,'group has qty and recs');
|
||||
var txs=groupTxs(g);
|
||||
assert(txs.length>=1,'group timeline from API transactions');
|
||||
/* ادمین */
|
||||
session={pw:'10'};
|
||||
return loadAll();
|
||||
}).then(function(ok){
|
||||
assert(ok,'admin loadAll ok');
|
||||
assert(scopedWarehouses().length>=3,'admin sees all warehouses');
|
||||
var h=authHeaders();
|
||||
assert(h['X-User-Role']==='admin','admin role header');
|
||||
assert(!h['X-Project-Id'],'admin has no project header');
|
||||
/* فرم خرید: ساخت payload */
|
||||
ui.form=emptyForm('PURCHASE');
|
||||
ui.form.whId=state.warehouses[0].id;
|
||||
ui.form.matTxt='میلگرد فولادی';
|
||||
ui.form.brandTxt='پارس';
|
||||
ui.form.unitTxt='کیلوگرم';
|
||||
ui.form.qty='5';
|
||||
ui.form.specsRows=[{rid:1,label:'سایز',value:'۱۱'}];
|
||||
assert(purchaseCanSubmit(),'purchase form valid');
|
||||
console.log('ALL_LOGIC_V3_OK');
|
||||
process.exit(0);
|
||||
}).catch(function(e){ console.error('THROW: '+(e.stack||e)); process.exit(1); });
|
||||
}catch(e){ console.error('THROW: '+(e.stack||e)); process.exit(1); }
|
||||
}, 400);
|
||||
setTimeout(function(){ console.error('THROW: test timeout'); process.exit(1); }, 15000);
|
||||
`;
|
||||
|
||||
const src = stubs + '\n' + core + '\n' + m1[1] + '\n' + m2[1] + '\n' + test;
|
||||
try {
|
||||
new Function(src)();
|
||||
} catch (e) {
|
||||
console.error('THROW: ' + (e && e.stack ? e.stack : e));
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user