chore: remove legacy HTML inventory-dash prototype (superseded by React port)

This commit is contained in:
ali_mohaghegh
2026-08-27 15:04:24 +03:30
parent debb193b90
commit f30c98adc0
11 changed files with 0 additions and 13817 deletions
-54
View File
@@ -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 مدیر پروژه الماس.
-6
View File
@@ -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'
};
-92
View File
@@ -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>
-9
View File
@@ -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'
};
-797
View File
@@ -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>
۱ ← مدیر پروژه برج آیتا — فاز ۳ (آقای موسوی) &nbsp;|&nbsp; ۲ ← مدیر پروژه برج آیتا — فاز ۳ (آقای گلی)<br>
۳ ← مدیر پروژه مجتمع تجاری الماس (آقای محقق) &nbsp;|&nbsp; ۱۰ ← ادمین (آقای رضایی — فقط خواندنی)
</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>
-582
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
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&&params[k]!==null&&params[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]:[];
}
-909
View File
@@ -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>
۱ ← مدیر پروژه برج آیتا — فاز ۳ (آقای موسوی) &nbsp;|&nbsp; ۲ ← مدیر پروژه برج آیتا — فاز ۳ (آقای گلی)<br>
۳ ← مدیر پروژه مجتمع تجاری الماس (آقای محقق) &nbsp;|&nbsp; ۱۰ ← ادمین (آقای رضایی — فقط خواندنی)
</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>
-19
View File
@@ -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()
-685
View File
@@ -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
-109
View File
@@ -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);
}