قبل ما تبدين
الصورة الكاملة
الملف الجاهز: 02_Attendance\2_Dashboard\PAP_Workforce_Dashboard.pbip (افتحيه في Power BI Desktop، اضغطي Refresh). المولّد 5_Scripts\att_workforce_pbip.py يعيد بناءه بأمر واحد لو تغير شي.
وش تجاوب الصفحة (من فوق لتحت)
| الجزء | السؤال | العنصر |
|---|---|---|
| العنوان | جملة تتغير مع الفلتر: الفترة، النسبة، والهدف | Card بمقياس [Title Text]: "Attendance 01 Sep to 05 Sep 2026: 93.4% of scheduled days on site, target 95%" |
| 6 مؤشرات | نسبة الحضور (مع الفرق عن الفترة السابقة)، الغياب غير المخطط، الإجازة المخططة، الموجودون، اكتمال التسجيل، الهيدكاونت | Attendance rate · Absenteeism, unplanned · Planned leave · On site · Records coverage · Active headcount |
| الصف الأول | الاتجاه يوم بيوم مقابل هدف 95%؟ وأي منطقة أعلى وأي منطقة أدنى؟ | Line (نسبة الحضور + خط الهدف المتقطع) · Bar بالمناطق مرتب تنازليًا ولونه يميل للنحاسي تحت الهدف |
| الصف الثاني | أي منطقة في أي يوم كانت ضعيفة؟ أي يوم من الأسبوع أضعف؟ ومين يحتاج سؤال؟ | Heatmap (منطقة × يوم) · Columns بأيام الأسبوع · جدول Needs attention (غياب × 3 + مرض × 2 + أيام بدون تسجيل) |
المعادلات (تعريفات AIHR و GoodShape)
| المؤشر | المعادلة | ليش |
|---|---|---|
| Scheduled days | Records − Day off − Exit | يوم الراحة الأسبوعي والخروج ليست أيام عمل، فلا تُحسب في المقام |
| Attendance rate | On site ÷ Scheduled days | الحضور نسبة من أيام العمل المجدولة، لا من كل السجلات |
| Absenteeism (unplanned) | (Absent + Sick) ÷ Scheduled days | AIHR: احسبي الغياب غير المخطط فقط؛ الإجازة الموافق عليها شي ثاني. المرجع العالمي 1.5% |
| Planned leave | (Vacation + Permission) ÷ Scheduled days | مخطط ومعروف مسبقًا، ما يُعاقب عليه القسم |
| Records coverage | Records ÷ (Active headcount × days) | ملفك لقطة يومية فيها فجوات؛ بدون هذا الرقم الفجوة تنقرأ غياب |
| Δ vs previous period | Rate − Rate(نفس عدد الأيام قبلها) | Zebra BI: الرقم بلا مقارنة ما يعني شي. يظهر بسهم ▲▼ تحت البطاقة |
| Attention score | Absent × 3 + Sick × 2 + Missed days | ترتيب بسيط لمين تسألين عنه أول (بديل مبسط لعامل Bradford) |
قواعد التصميم اللي مشينا عليها (ومن وين)
- شاشة واحدة بدون تمرير، والأهم فوق يسار (Stephen Few، Microsoft): البطاقات أولًا، الاتجاه ثانيًا، التفاصيل تحت.
- ثمانية عناصر كحد أقصى وجدول واحد (MAQ Software، Zebra BI): عندنا 5 رسوم وجدول واحد.
- كل بطاقة تحمل مقارنة (Zebra BI): هدف أو فترة سابقة، لا رقم عاري.
- الأعمدة أفضل من الدوائر (Microsoft Learn: Pie وDonut وGauge "ليست مثالية")؛ لذلك حذفنا القيج والدونات.
- ترتيب الأعمدة تنازليًا حين تكون الرسالة ترتيبًا (Microsoft).
- الزمن أفقي، المقارنة بين الفئات عمودية (IBCS).
- العنوان جملة: الموضوع + المقياس + الفترة + الهدف (IBCS، Zebra BI).
- 3 إلى 5 ألوان ولون واحد للتنبيه (Lukas Reese، Zebra BI): Phosphate أساسي، Gold ثانوي، Copper للهدف وما تحته، Crimson للغياب فقط.
- شبكة 8px: هامش 32، فراغ 16، ارتفاعات مضاعفات 8 (Zebra BI، Lukas Reese).
- خط الهدف بدل سلسلة زيادة (Microsoft Analytics pane): عندنا مقياس ثابت Target 95% كخط متقطع.
- Heatmap = Matrix + تلوين شرطي (Coupler، Microsoft): أغمق = أفضل.
الخطوة 1
ربط الملفات الثلاثة: كيف يتحدث واحد ويظهر في الثاني
ما فيه ملف "يقرأ" ملفًا آخر. الثلاثة مستقلون، وPower BI هو اللي يربطهم وقت التحديث. الربط بمفتاح واحد: الرقم.
| الملف | وين يعيش | الجدول اللي يقرأه Power BI | المفتاح | مين يكتب فيه |
|---|---|---|---|---|
| Daily Attendance.xlsm | OneDrive (مشارك مع مسجلي الحضور، يفتحونه بالديسكتوب عشان الماكرو) | AttendanceTracker | EmployeeID = رقم السويدي للمقاول، أو رقم الموظف للمباشر | مسؤول الحضور |
| PAP_Mech_Employee_Master.xlsx | SharePoint القسم | tblEmployees | MaadenID (المفتاح الرسمي) و SIS_ID | أنتي |
| MasterSheet.xlsx (السويدي) | SharePoint، تحفظينه فوق القديم بنفس الاسم كل ما يوصلك | Table7 | SIS FUSION NUMBER | المقاول (أنتي تحفظينه بس) |
مفتاح الربط: LinkID
ملف الحضور يكتب رقم السويدي للمقاولين ورقم معادن للمباشرين. فبدل ما نغير ملف الحضور، نصنع في الماستر عمودًا محسوبًا في Power Query:
LinkID = if [SIS_ID] <> null and Text.Trim([SIS_ID]) <> "" then Text.Trim([SIS_ID]) else Text.Trim([MaadenID])
وبعدها العلاقة: Attendance[EmployeeID] ← Employees[LinkID]، وContractor[SIS] ← Employees[LinkID]. الاثنان يوصلون لنفس الموظف بدون ما نغير أي ملف مصدر. ولما تحوّلين الحضور لرقم معادن لاحقًا، LinkID يصير MaadenID بس، والعلاقة نفسها.
لما ينضاف موظف جديد
- يوصلك MasterSheet جديد من السويدي: احفظيه فوق القديم بنفس الاسم.
- Refresh: صفحة Contractor & quality تطلع لك "New on contractor list": اسمه ورقمه وتاريخ التعبئة ورقم معادن.
- أضيفيه سطرًا في الماستر (رقم معادن، SIS، الكرافت، المنطقة، الشفت الافتراضي). Refresh مرة ثانية: يختفي من القائمة ويظهر في Headcount، وحضوره يبدأ ينربط من أول يوم يسجله المسؤول.
لما تتغير حالة موظف
لو المقاول كتب Vacation وأنتي عندك Active (أو العكس)، يطلع في "Status mismatch". تصححين الماستر، وتتفقين مع المقاول على المصدر الأصح. والإجازة تظهر في Today كـ Vacation في ملف الحضور، وفي Workforce كـ On vacation من الماستر.
الروابط بدل المسارات (عشان ينشر ويتحدث لحاله)
الملف الجاهز يقرأ من مسارات جهازك. في الدوام غيري سطر Source في كل استعلام (Transform data ← الاستعلام ← الخطوة الأولى) إلى رابط الملف:
// ملف على SharePoint أو OneDrive for Business: خذي الرابط من Excel: File ← Info ← Copy path، واحذفي ?web=1 Source = Excel.Workbook(Web.Contents("https://maaden.sharepoint.com/sites/PAP-Mech/Shared%20Documents/PAP_Mech_Employee_Master.xlsx"), null, true), // نفس الشي لملف الحضور على OneDrive (ملف xlsm يُقرأ عادي، الماكرو ما يهم Power BI) Source = Excel.Workbook(Web.Contents("https://maaden-my.sharepoint.com/personal/alzahraniraw_maaden_com_sa/Documents/Attendance/Daily%20Attendance.xlsm"), null, true),
الخطوة 2
Power Query: الجداول الأربعة
لكل جدول: Get Data ← Blank Query ← Advanced Editor ← الصقي الكود ← سمّي الاستعلام بنفس الاسم. غيري سطر Source فقط.
Employees · ماستر الموظفين31 عمود
- LinkID: مفتاح الربط
- TenureYears: من تاريخ التعبئة
- IDDaysLeft / IDStatus / IDSort: انتهاء الهوية (سالب = منتهية، ≤60، ≤90، Valid، No date)
- HasMaadenID، AreaClean، CategoryClean، NationalityClean: الفاضي يصير نصًا واضحًا بدل (Blank)
- وبعدها 5 أعمدة DAX محسوبة (في القسم 3)
let
Source = Excel.Workbook(File.Contents("C:\\Users\\rawab\\OneDrive\\Desktop\\معادن - روابي\\02_Attendance\\3_Output\\PAP_Mech_Employee_Master.xlsx"), null, true),
T = Source{[Item="tblEmployees", Kind="Table"]}[Data],
Kept = Table.SelectColumns(T, {"MaadenID", "SIS_ID", "EmployeeName", "Craft", "Area", "Category", "ContractType", "EmploymentStatus", "Nationality", "Type of ID", "ID Expire Date", "JoiningDate", "ExitDate", "WorkingDays", "WorkingHours", "DefaultShift", "Note"}),
NoBlank = Table.SelectRows(Kept, each [EmployeeName] <> null and [EmployeeName] <> ""),
Typed = Table.TransformColumnTypes(NoBlank, {{"MaadenID", type text}, {"SIS_ID", type text}, {"EmployeeName", type text}, {"Craft", type text}, {"Area", type text}, {"Category", type text}, {"ContractType", type text}, {"EmploymentStatus", type text}, {"Nationality", type text}, {"Type of ID", type text}, {"ID Expire Date", type date}, {"JoiningDate", type date}, {"ExitDate", type date}, {"WorkingDays", Int64.Type}, {"WorkingHours", Int64.Type}, {"DefaultShift", type text}, {"Note", type text}}),
Today = Date.From(DateTime.LocalNow()),
// مفتاح الربط مع ملف الحضور: رقم السويدي للمقاول، ورقم معادن للمباشر
A1 = Table.AddColumn(Typed, "LinkID", each if [SIS_ID] <> null and Text.Trim([SIS_ID]) <> "" then Text.Trim([SIS_ID]) else Text.Trim([MaadenID]), type text),
A2 = Table.AddColumn(A1, "TenureYears", each if [JoiningDate] = null then null else Number.Round(Duration.Days(Today - [JoiningDate]) / 365.25, 1), type number),
A3 = Table.AddColumn(A2, "IDDaysLeft", each if [ID Expire Date] = null then null else Duration.Days([ID Expire Date] - Today), Int64.Type),
A4 = Table.AddColumn(A3, "IDStatus", each if [ID Expire Date] = null then "No date" else if [IDDaysLeft] < 0 then "Expired" else if [IDDaysLeft] <= 60 then "Due in 60 days" else if [IDDaysLeft] <= 90 then "Due in 90 days" else "Valid", type text),
A5 = Table.AddColumn(A4, "IDSort", each if [IDStatus] = "Expired" then 1 else if [IDStatus] = "Due in 60 days" then 2 else if [IDStatus] = "Due in 90 days" then 3 else if [IDStatus] = "Valid" then 4 else 5, Int64.Type),
A6 = Table.AddColumn(A5, "HasMaadenID", each if [MaadenID] = null or Text.Trim([MaadenID]) = "" then "Missing" else "Yes", type text),
A7 = Table.AddColumn(A6, "AreaClean", each if [Area] = null or Text.Trim([Area]) = "" then "(No area)" else [Area], type text),
A8 = Table.AddColumn(A7, "CategoryClean", each if [Category] = null or Text.Trim([Category]) = "" then "(No category)" else [Category], type text),
A9 = Table.AddColumn(A8, "NationalityClean", each if [Nationality] = null or Text.Trim([Nationality]) = "" then "(Not on contractor list)" else [Nationality], type text)
in
A9Attendance · سجل الحضور11 عمود
- Merge مع Employees على LinkID (نفس فكرة XLOOKUP) يجيب DefaultShift
- Shift: Day/Night كما كُتب، أو On Duty ← الشفت الافتراضي
- Presence: On site / Absent / Sick leave / Vacation / Day off / Permission / Exit
- OnSite: 1 أو 0 للجمع
- InMaster: هل الرقم موجود في الماستر
let
Source = Excel.Workbook(File.Contents("C:\\Users\\rawab\\OneDrive\\Desktop\\معادن - روابي\\02_Attendance\\0_Source\\Daily_Attendance_Copy_2026-09-14.xlsm"), null, true),
T = Source{[Item="AttendanceTracker", Kind="Table"]}[Data],
Kept = Table.SelectColumns(T, {"Date", "Day", "EmployeeID", "EmployeeName", "Status"}),
NoBlank = Table.SelectRows(Kept, each [EmployeeID] <> null and Text.From([EmployeeID]) <> "" and [Date] <> null),
Typed = Table.TransformColumnTypes(NoBlank, {{"Date", type date}, {"Day", type text}, {"EmployeeID", type text}, {"EmployeeName", type text}, {"Status", type text}}),
Trim = Table.TransformColumns(Typed, {{"EmployeeID", Text.Trim, type text}, {"Status", Text.Trim, type text}}),
// الشفت الافتراضي يجي من ملف الموظفين (Merge = XLOOKUP)
J = Table.NestedJoin(Trim, {"EmployeeID"}, Employees, {"LinkID"}, "E", JoinKind.LeftOuter),
X = Table.ExpandTableColumn(J, "E", {"DefaultShift", "LinkID"}, {"DefaultShift", "MatchedID"}),
B1 = Table.AddColumn(X, "Shift", each if [Status] = "Day" or [Status] = "Night" then [Status] else if [Status] = "On Duty" then (if [DefaultShift] = null then "Day" else [DefaultShift]) else null, type text),
B2 = Table.AddColumn(B1, "Presence", each if [Shift] <> null then "On site" else if [Status] = "Absent" then "Absent" else if [Status] = "Sick Leave" then "Sick leave" else if [Status] = "Vacation" then "Vacation" else if [Status] = "Off" then "Day off" else if [Status] = "Permission" then "Permission" else if [Status] = "Exit" then "Exit" else "Other", type text),
B3 = Table.AddColumn(B2, "PresenceSort", each if [Presence] = "On site" then 1 else if [Presence] = "Absent" then 2 else if [Presence] = "Sick leave" then 3 else if [Presence] = "Permission" then 4 else if [Presence] = "Vacation" then 5 else if [Presence] = "Day off" then 6 else if [Presence] = "Exit" then 7 else 8, Int64.Type),
B4 = Table.AddColumn(B3, "OnSite", each if [Shift] <> null then 1 else 0, Int64.Type),
B5 = Table.AddColumn(B4, "InMaster", each if [MatchedID] = null then "Not in master" else "Yes", type text),
Out = Table.RemoveColumns(B5, {"MatchedID"})
in
OutContractor · قائمة المقاول13 عمود
- إعادة تسمية الأعمدة لأسماء مفهومة
- SIS كنص (عشان يطابق)
- التواريخ النصية مثل 0-Jan-00 تصير فاضية بدل ما تكسر التحديث (try … otherwise null)
- Status و Nationality بـ Text.Proper
let
Source = Excel.Workbook(File.Contents("C:\\Users\\rawab\\OneDrive\\Desktop\\معادن - روابي\\02_Attendance\\0_Source\\Suwaidi_MasterSheet_2026-09-14.xlsx"), null, true),
T = Source{[Item="Table7", Kind="Table"]}[Data],
Kept = Table.SelectColumns(T, {"SIS FUSION NUMBER", "MPC ID #", "MPC ID EXPIRY DATE", "NAME", "UTILIZATION TRADE", "STATUS", "MOBILIZED DATE", "DMOBD", "REASON FOR DMOB", "VAC /EXIT DATE", "NATIONALITY", "REMARKS"}),
Renamed = Table.RenameColumns(Kept, {{"SIS FUSION NUMBER", "SIS"}, {"MPC ID #", "Maaden ID (contractor)"}, {"MPC ID EXPIRY DATE", "ID expiry (contractor)"}, {"NAME", "Name"}, {"UTILIZATION TRADE", "Trade"}, {"STATUS", "Status"}, {"MOBILIZED DATE", "Mobilized"}, {"DMOBD", "DMOB date"}, {"REASON FOR DMOB", "DMOB reason"}, {"VAC /EXIT DATE", "Vac/Exit date"}, {"NATIONALITY", "Nationality"}, {"REMARKS", "Remarks"}}),
NoBlank = Table.SelectRows(Renamed, each [SIS] <> null and Text.From([SIS]) <> ""),
AsText = Table.TransformColumns(NoBlank, {{"SIS", each Text.Trim(Text.From(_)), type text}, {"Maaden ID (contractor)", each if _ = null then null else Text.Trim(Text.From(_)), type text}}),
// التاريخ اللي يوصل نص مثل 0-Jan-00 يصير فاضي بدل ما يكسر التحديث
Dates = Table.TransformColumns(AsText, {{"ID expiry (contractor)", each try Date.From(_) otherwise null, type date}, {"Mobilized", each try Date.From(_) otherwise null, type date}, {"DMOB date", each try Date.From(_) otherwise null, type date}, {"Vac/Exit date", each try Date.From(_) otherwise null, type date}}),
Proper = Table.TransformColumns(Dates, {{"Status", each Text.Proper(Text.Trim(Text.From(_))), type text}, {"Nationality", each if _ = null then null else Text.Proper(Text.Trim(Text.From(_))), type text}, {"Name", each if _ = null then null else Text.Proper(Text.Trim(Text.From(_))), type text}}),
Typed = Table.TransformColumnTypes(Proper, {{"Trade", type text}, {"DMOB reason", type text}, {"Remarks", type text}})
in
TypedCalendar · التقويم9 عمود
- من 1 أغسطس 2026 لسنة ونص
- DayName مرتب بالأحد أولًا، Week، MonthName، IsFriSat
let
D0 = #date(2026, 8, 1),
Days = List.Dates(D0, 517, #duration(1, 0, 0, 0)),
T = Table.FromList(Days, Splitter.SplitByNothing(), {"Date"}),
T1 = Table.TransformColumnTypes(T, {{"Date", type date}}),
T2 = Table.AddColumn(T1, "DayName", each Date.ToText([Date], "ddd"), type text),
T3 = Table.AddColumn(T2, "DaySort", each Date.DayOfWeek([Date], Day.Sunday), Int64.Type),
T4 = Table.AddColumn(T3, "WeekStart", each Date.StartOfWeek([Date], Day.Sunday), type date),
T5 = Table.AddColumn(T4, "Week", each "W" & Text.From(Date.WeekOfYear([Date], Day.Sunday)), type text),
T6 = Table.AddColumn(T5, "WeekSort", each Date.Year([Date]) * 100 + Date.WeekOfYear([Date], Day.Sunday), Int64.Type),
T7 = Table.AddColumn(T6, "MonthName", each Date.ToText([Date], "MMM yyyy"), type text),
T8 = Table.AddColumn(T7, "MonthSort", each Date.Year([Date]) * 100 + Date.Month([Date]), Int64.Type),
T9 = Table.AddColumn(T8, "IsFriSat", each if Date.DayOfWeek([Date], Day.Sunday) >= 5 then "Fri/Sat" else "Workday", type text)
in
T9ليش الأعمدة المحسوبة في Power Query مو في DAX؟ لأنها ثابتة لكل صف (تاريخ، نص). DAX للأعمدة اللي تحتاج علاقة (مثل "هل موجود عند المقاول") وللمقاييس اللي تتغير مع الفلتر.
الخطوة 3
النموذج والعلاقات
| من (الكثير) | إلى (الواحد) | الاتجاه | ليش |
|---|---|---|---|
Attendance[EmployeeID] | Employees[LinkID] | Many to one · Single | كل سطر حضور يعرف صاحبه ومنطقته وكرافته |
Attendance[Date] | Calendar[Date] | Many to one · Single | محور التاريخ والأسبوع واليوم |
Contractor[SIS] | Employees[LinkID] | Many to one · Single | كل سطر حضور يعرف صاحبه ومنطقته وكرافته |
أعمدة DAX المحسوبة (Modeling ← New column)
هذي تحتاج العلاقة، فمكانها DAX:
IF ( CALCULATE ( COUNTROWS ( Contractor ) ) > 0, "Yes", IF ( Employees[ContractType] = "Direct", "n/a (direct)", "No" ) )
CALCULATE ( MAX ( Contractor[Status] ) )
VAR c = Employees[ContractorStatus] RETURN IF ( ISBLANK ( c ), "n/a", IF ( c = Employees[EmploymentStatus], "Match", "Mismatch: ours " & Employees[EmploymentStatus] & " / contractor " & c ) )
IF ( Employees[EmploymentStatus] = "Vacation", CALCULATE ( MAX ( Contractor[DMOB date] ) ) )
SWITCH ( TRUE (), Employees[InContractorList] = "No", "Not on contractor list", LEFT ( Employees[StatusMatch], 8 ) = "Mismatch", Employees[StatusMatch], "OK" )
IF ( ISBLANK ( RELATED ( Employees[MaadenID] ) ) && ISBLANK ( RELATED ( Employees[EmployeeName] ) ), "New: not in our master", "In master" )
Sort by column وإخفاء
IDStatusيترتب بـ IDSort، وPresenceبـ PresenceSort، وDayNameبـ DaySort، وWeekبـ WeekSort، وMonthNameبـ MonthSort.- أخفي: LinkID، IDSort، PresenceSort، OnSite، DaySort، WeekSort، MonthSort.
- علّمي Calendar كـ Date table (كليك يمين ← Mark as date table).
الخطوة 4
المقاييس: 61 مقياسًا في جدول _Measures
Enter data ← اسمه _Measures ← Load. ثم لكل مقياس New measure والصقي. المجلدات (Display folder) تخلي القائمة مرتبة.
01 Attendance · الحضور: عدّ الصفوف حسب Presence20 مقياس
COUNTROWS ( Attendance ) + 0
SUM ( Attendance[OnSite] ) + 0
CALCULATE ( [On Site], Attendance[Shift] = "Day" )
CALCULATE ( [On Site], Attendance[Shift] = "Night" )
CALCULATE ( [Records], Attendance[Presence] = "Absent" )
CALCULATE ( [Records], Attendance[Presence] = "Sick leave" )
CALCULATE ( [Records], Attendance[Presence] = "Vacation" )
CALCULATE ( [Records], Attendance[Presence] = "Day off" )
CALCULATE ( [Records], Attendance[Presence] = "Permission" )
CALCULATE ( [Records], Attendance[Presence] = "Exit" )
DISTINCTCOUNT ( Attendance[EmployeeID] ) + 0
CALCULATE ( DISTINCTCOUNT ( Attendance[Date] ) ) + 0
CALCULATE ( COUNTROWS ( Employees ), Employees[EmploymentStatus] = "Active" ) + 0
[Active Headcount] * [Days In View]
MAX ( 0, [Expected Records] - [Records] )
[On Site]
MAX ( 0, [Records] - [Att Day off] - [Att Exit] )
CALCULATE ( DISTINCTCOUNT ( Attendance[Date] ), REMOVEFILTERS ( Employees ), REMOVEFILTERS ( Attendance[EmployeeID] ) ) + 0
IF ( SELECTEDVALUE ( Employees[EmploymentStatus] ) = "Active", MAX ( 0, [Days In View All] - [Records] ), 0 )
[Att Absent] * 3 + [Att Sick leave] * 2 + [Missed Days]
02 Ratios · النسب: القسمة الآمنة بـ DIVIDE16 مقياس
DIVIDE ( [Records], [Expected Records] )
DIVIDE ( [On Site], [Records] )
DIVIDE ( [Att Absent] + [Att Sick leave], [Records] )
DIVIDE ( [Att Vacation] + [Att Day off], [Records] )
DIVIDE ( [Night Shift], [On Site] )
DIVIDE ( [On Site], [Days In View] )
MAX ( Attendance[Date] )
1
1
DIVIDE ( [On Site], [Scheduled Days] )
DIVIDE ( [Att Absent] + [Att Sick leave], [Scheduled Days] )
DIVIDE ( [Att Vacation] + [Att Permission], [Scheduled Days] )
0.95
0.015
VAR n = [Days In View] VAR d1 = MIN ( Attendance[Date] ) RETURN IF ( n > 0, CALCULATE ( [Attendance Rate %], DATESBETWEEN ( Calendar[Date], d1 - n, d1 - 1 ) ) )
VAR n = [Days In View] VAR d1 = MIN ( Attendance[Date] ) RETURN IF ( n > 0, CALCULATE ( [Absenteeism %], DATESBETWEEN ( Calendar[Date], d1 - n, d1 - 1 ) ) )
03 Workforce · القوى العاملة: من الماستر10 مقياس
CALCULATE ( COUNTROWS ( Employees ), Employees[EmploymentStatus] IN { "Active", "Vacation" } ) + 0
CALCULATE ( COUNTROWS ( Employees ), Employees[EmploymentStatus] = "Vacation" ) + 0
CALCULATE ( COUNTROWS ( Employees ), Employees[EmploymentStatus] IN { "Exit", "Resigned", "Demobilized" } ) + 0
CALCULATE ( [Headcount], Employees[ContractType] = "Direct" )
CALCULATE ( [Headcount], Employees[ContractType] = "Subcontract" )
DIVIDE ( CALCULATE ( [Headcount], Employees[Type of ID] IN { "Permanent", "Permanent (Old ID)", "Maaden Staff" } ), [Headcount] )
CALCULATE ( [Headcount], Employees[Type of ID] = "TAM" )
AVERAGE ( Employees[TenureYears] )
CALCULATE ( [Headcount], Employees[DefaultShift] = "Night" )
CALCULATE ( COUNTROWS ( Employees ), Employees[HasMaadenID] = "Missing" ) + 0
04 IDs & leave · الهويات والإجازات5 مقياس
CALCULATE ( [Headcount], Employees[IDStatus] = "Expired" )
CALCULATE ( [Headcount], Employees[IDStatus] = "Due in 60 days" )
CALCULATE ( [Headcount], Employees[IDStatus] = "Due in 90 days" )
CALCULATE ( [Headcount], Employees[IDStatus] = "No date" )
COUNTROWS ( Employees ) + 0
05 Contractor & quality · المقاول وجودة البيانات6 مقياس
COUNTROWS ( Contractor ) + 0
CALCULATE ( COUNTROWS ( Contractor ), Contractor[InMaster] = "New: not in our master" ) + 0
CALCULATE ( COUNTROWS ( Employees ), Employees[InContractorList] = "No" ) + 0
CALCULATE ( COUNTROWS ( Employees ), LEFT ( Employees[StatusMatch], 8 ) = "Mismatch", Employees[EmploymentStatus] IN { "Active", "Vacation" } ) + 0
CALCULATE ( DISTINCTCOUNT ( Attendance[EmployeeID] ), Attendance[InMaster] = "Not in master" ) + 0
CALCULATE ( COUNTROWS ( Employees ), Employees[AreaClean] = "(No area)" || Employees[CategoryClean] = "(No category)" ) + 0
06 Labels · النصوص الصغيرة تحت الأرقام25 مقياس
"Last day recorded " & FORMAT ( [Last Recorded], "dd MMM yyyy" ) & " · " & FORMAT ( [Active Headcount], "#,0" ) & " active employees"
"Day " & FORMAT ( [Day Shift], "#,0" ) & " · night " & FORMAT ( [Night Shift], "#,0" )
"Recorded " & FORMAT ( [Records], "#,0" ) & " of " & FORMAT ( [Expected Records], "#,0" ) & " expected"
"Absent " & FORMAT ( [Att Absent], "#,0" ) & " · sick " & FORMAT ( [Att Sick leave], "#,0" )
"Vacation " & FORMAT ( [Att Vacation], "#,0" ) & " · off " & FORMAT ( [Att Day off], "#,0" )
"Across " & FORMAT ( [Days In View], "#,0" ) & " day(s) in the filter"
"Distinct people seen " & FORMAT ( [People Seen], "#,0" )
"Direct " & FORMAT ( [Direct Staff], "#,0" ) & " · subcontract " & FORMAT ( [Subcontract Staff], "#,0" )
"Exited or moved " & FORMAT ( [Exited], "#,0" )
"TAM " & FORMAT ( [TAM Count], "#,0" ) & " · night default " & FORMAT ( [Night Default], "#,0" )
"Years since mobilisation, average"
"No expiry date on file: " & FORMAT ( [IDs No Date], "#,0" )
"Cannot be linked to attendance"
"On Al Suwaidi list, missing from our master"
"Subcontract in our master, not on their list"
"Our status vs contractor status"
"Numbers typed in attendance with no employee"
VAR d0 = MIN ( Attendance[Date] ) VAR d1 = MAX ( Attendance[Date] ) RETURN IF ( ISBLANK ( d0 ), "no records", IF ( d0 = d1, FORMAT ( d0, "dd MMM yyyy" ), FORMAT ( d0, "dd MMM" ) & " to " & FORMAT ( d1, "dd MMM yyyy" ) ) )
"Attendance " & [Period Label] & ": " & FORMAT ( [Attendance Rate %], "0.0%" ) & " of scheduled days on site, target 95%"
VAR p = [Attendance Rate Prev] VAR d = [Attendance Rate %] - p RETURN IF ( ISBLANK ( p ), "No previous period to compare", IF ( d >= 0, UNICHAR ( 9650 ), UNICHAR ( 9660 ) ) & " " & FORMAT ( ABS ( d ) * 100, "0.0" ) & " pp vs previous " & FORMAT ( [Days In View], "#,0" ) & " days" )
VAR p = [Absenteeism Prev] VAR d = [Absenteeism %] - p RETURN "Absent " & FORMAT ( [Att Absent], "#,0" ) & " · sick " & FORMAT ( [Att Sick leave], "#,0" ) & IF ( ISBLANK ( p ), "", " · " & IF ( d > 0, UNICHAR ( 9650 ), UNICHAR ( 9660 ) ) & " " & FORMAT ( ABS ( d ) * 100, "0.0" ) & " pp" )
"Vacation " & FORMAT ( [Att Vacation], "#,0" ) & " · permission " & FORMAT ( [Att Permission], "#,0" )
"Recorded " & FORMAT ( [Records], "#,0" ) & " of " & FORMAT ( [Expected Records], "#,0" ) & " · missing " & FORMAT ( [Not Recorded], "#,0" )
"Last day recorded " & FORMAT ( [Last Recorded], "dd MMM yyyy" ) & " · " & FORMAT ( [Active Headcount], "#,0" ) & " active · " & FORMAT ( [Scheduled Days], "#,0" ) & " scheduled days"
VAR r = [Attendance Rate %] RETURN SWITCH ( TRUE (), ISBLANK ( r ), "#FFFFFF", r >= 0.95, "#B8A567", r >= 0.85, "#D9CFA3", "#F1EACB" )
• Not recorded = المتوقع − المسجل، ولا ينزل تحت الصفر.
• + 0 في آخر مقاييس العد عشان البطاقة تكتب 0 بدل فراغ. وفي الرسوم نضيف فلتر EmploymentStatus عشان الصفر ما يصنع فئة (Blank).
الخطوة 5
نظام التصميم: كل شي من ثلاث وصفات
الصفحة 1280 × 720. الهامش 48 من اليمين واليسار (المحتوى من x=48 إلى 1232). الفراغ بين العناصر 12. الخط Segoe UI في كل شي.
| اللون | الكود | وين |
|---|---|---|
| Bone | #F7F3E9 | أرضية الصفحة (Format page ← Canvas background) |
| أبيض | #FFFFFF | كل مستطيل وبطاقة |
| Phosphate | #343631 | الأرقام الكبيرة، العناوين، الأعمدة الرئيسية |
| Gold | #B8A567 | الخط تحت الترويسة، السلسلة الثانية (Night)، التبويب النشط |
| Iron / Aluminum | #5F5E5B / #BDBAB5 | النصوص الصغيرة، الملاحظات |
| Copper / Crimson | #C76210 / #8A340F | تحذير / سيء (الغياب، المنتهي) |
| Moonstone | #E0DEDB | الحدود والخطوط الفاصلة |
2) مستطيل ثاني: x0 y66 w1280 h3، تعبئة Gold. هذا الخط الذهبي.
3) Insert ← Image: شعار معادن x48 y15 w168 h32.
4) Text box: "PHOSPHORIC ACID PLANT · MECHANICAL MAINTENANCE · WORKFORCE" حجم 7 عريض Gold، x238 y3.
5) Card بالمقياس [Title Text] كعنوان: Callout حجم 13 Segoe UI Semibold لون Phosphate، Category label off، x218 y20 w760 h34. يتغير مع الفلتر.
6) Card بالمقياس [Sub Headline] حجم 7.5 رمادي، x908 y30 w340 (آخر يوم مسجل، النشطون، أيام العمل المجدولة).
الترويسة ارتفاعها 56 والخط الذهبي عند y=56. لا أزرار تنقل: صفحة واحدة.
Format للـ slicer: Header off، Values حجم 8، خلفية أبيض.
1) مستطيل أبيض 189×88.
2) مستطيل رفيع فوقه 189×3 بلون البطاقة (Phosphate / Crimson / Copper / Gold / Aluminum / Earth).
3) Text box بالعنوان بأحرف كبيرة، حجم 7 عريض Iron، y+6.
4) Card بالمقياس الرئيسي: Callout value حجم 20 Phosphate (أو Crimson للسيء)، Category label off، y+28.
5) Card بمقياس النص الصغير (Sub …): حجم 7.5 Iron، y+60.
بعد أول بطاقة: Group ← Copy ← Paste خمس مرات، وغيري المقياسين واللون بس.
الرسوم: محور X حجم 9 رمادي، عنوان المحاور off، خطوط الشبكة فاتحة #EFEAE0، Data labels حجم 8 حيث مذكور.
الجداول: رأس الأعمدة خلفية Bone نص Iron حجم 8، القيم حجم 8 مع صفوف متبادلة أبيض/#FBF9F4، خطوط أفقية فقط، Totals off.
الصفوف: البطاقات من y=110 بارتفاع 88، الصف الأول من y=214 بارتفاع 224 (Line عرض 800، ثم Bar عرض 400)، الصف الثاني من y=454 بارتفاع 222 (Heatmap 456، Columns 304، الجدول 424)، والتذييل عند y=686. الهامش 32 والفراغ 16.
Lines ← للسلسلة Attendance Rate %: عرض 2، Markers on، لون Phosphate. للسلسلة Target 95%: عرض 1، Dashed، بدون markers، لون Copper.
Y axis: Range من 0.7 إلى 1 (وتكتبين في العنوان الفرعي إن المحور يبدأ من 70%). Data labels on للسلسلة الأولى فقط (Series ← Target off). Legend فوق.
Filters on this visual: Records أكبر من 0 (عشان الأيام الفاضية ما تطلع).
Bars ← Colors ← fx ← Format style = Gradient، Based on = Attendance Rate %، Minimum = Custom 0.85 لون Copper، Maximum = Custom 0.95 لون Phosphate.
X axis off، Range 0 إلى 1.18 (عشان تطلع الأرقام برا العمود)، Data labels on حجم 8.
Filters: InMaster = Yes، EmploymentStatus = Active أو Vacation.
أضيفي مقياس اللون:
Heat Colour = VAR r = [Attendance Rate %] RETURN SWITCH ( TRUE (), ISBLANK ( r ), "#FFFFFF", r >= 0.95, "#B8A567", r >= 0.85, "#D9CFA3", "#F1EACB" )Cell elements ← Series = Attendance Rate % ← Background color on ← fx ← Format style = Field value ← What field = Heat Colour.
Style presets = None، Row padding 2، Values وColumn headers محاذاة Center، Subtotals off.
Filters: EmploymentStatus = Active، Craft ليس Fire Watch / Fabricator / Welder-CS، Attention Score أكبر من 0.
Missed Days = IF ( SELECTEDVALUE ( Employees[EmploymentStatus] ) = "Active", MAX ( 0, [Days In View All] - [Records] ), 0 ) حيث Days In View All يحسب أيام الفلتر بعد إزالة فلتر الموظف (REMOVEFILTERS).الخطوة 6
الصفحة الواحدة، عنصرًا عنصرًا
الترتيب من فوق لتحت. المواقع بالبكسل: Format ← General ← Properties. الحقول: اسحبيها من قائمة البيانات. الفلاتر: Filters on this visual.
الحضور · Attendance
مين موجود، مين ناقص، وكيف شكل الأسبوع.
| # | النوع | العنوان | الحقول | X, Y | W × H | فلتر العنصر |
|---|---|---|---|---|---|---|
| 1 | Card | Title Text | 218, 20 | 760 × 34 | — | |
| 2 | Card | Sub Headline | 908, 30 | 340 × 24 | — | |
| 3 | Slicer | Date | 96, 66 | 186 × 30 | — | |
| 4 | Slicer | AreaClean | 340, 66 | 150 × 30 | — | |
| 5 | Slicer | Craft | 548, 66 | 176 × 30 | — | |
| 6 | Slicer | CategoryClean | 808, 66 | 150 × 30 | — | |
| 7 | Slicer | ContractType | 1038, 66 | 108 × 30 | — | |
| 8 | Text box | «ATTENDANCE RATE» | — | 44, 116 | 169 × 24 | — |
| 9 | Text box | «ABSENTEEISM, UNPLANNED» | — | 249, 116 | 169 × 24 | — |
| 10 | Text box | «PLANNED LEAVE» | — | 454, 116 | 169 × 24 | — |
| 11 | Text box | «ON SITE» | — | 659, 116 | 169 × 24 | — |
| 12 | Text box | «RECORDS COVERAGE» | — | 864, 116 | 169 × 24 | — |
| 13 | Text box | «ACTIVE HEADCOUNT» | — | 1069, 116 | 169 × 24 | — |
| 14 | Card | Attendance Rate % | 39, 138 | 175 × 32 | — | |
| 15 | Card | Absenteeism % | 244, 138 | 175 × 32 | — | |
| 16 | Card | Planned Leave % | 449, 138 | 175 × 32 | — | |
| 17 | Card | On Site | 654, 138 | 175 × 32 | — | |
| 18 | Card | Coverage % | 859, 138 | 175 × 32 | — | |
| 19 | Card | Active Headcount | 1064, 138 | 175 × 32 | — | |
| 20 | Card | Sub Attendance | 39, 170 | 175 × 22 | — | |
| 21 | Card | Sub Absenteeism | 244, 170 | 175 × 22 | — | |
| 22 | Card | Sub Planned | 449, 170 | 175 × 22 | — | |
| 23 | Card | Sub On Site | 654, 170 | 175 × 22 | — | |
| 24 | Card | Sub Coverage v3 | 859, 170 | 175 × 22 | — | |
| 25 | Card | Sub Headcount | 1064, 170 | 175 × 22 | — | |
| 26 | lineChart | Attendance rate by day, % of scheduled days | Date Attendance Rate % Target 95% | 32, 214 | 800 × 224 | [Records] > 0 |
| 27 | Clustered bar | Attendance rate by area, highest first | AreaClean Attendance Rate % | 848, 214 | 400 × 224 | InMaster in Yes · EmploymentStatus in Active, Vacation |
| 28 | Matrix | Attendance rate, area by day | AreaClean Date Attendance Rate % | 32, 454 | 456 × 222 | InMaster in Yes · EmploymentStatus in Active, Vacation |
| 29 | Clustered column | By weekday | DayName Attendance Rate % | 504, 454 | 304 × 222 | [Records] > 0 |
| 30 | Table | Needs attention | EmployeeName Att Absent Att Sick leave Missed Days Attention Score | 824, 454 | 424 × 222 | EmploymentStatus in Active · Craft not in Fire Watch, Fabricator, Welder-CS · [Attention Score] > 0 |
| 31 | Text box | تذييل: «Attendance | scheduled days = recorded days minus rest days and exits | absenteeism counts unplanned absence only (absent, sick)» | — | 32, 686 | 560 × 30 | — |
| 32 | Text box | تذييل: «Attendance rate = on site ÷ scheduled days. Coverage = records ÷ (active × days). Sources: attendance macro file on OneDrive + employee master» | — | 588, 686 | 660 × 30 | — |
البطاقات: Attendance rate 93.4%، Absenteeism 0.3%، Planned leave 6.3%، On site 537، Records coverage 70.4%، Active headcount 168.
الخط: 93.3% · 91.2% · 89.6% · 100% · 100% تحت خط الهدف المتقطع. الأعمدة: Train C 100% أعلى، Cooling Tower 93.0% أدنى. الجدول: Narciso Jr. Gallego Dellosa أول اسم (Score 8).
مقاييس جاهزة لو احتجتي صفحة ثانية لاحقًا: IDs Expired، IDs Due 60، On Vacation، New In Contractor List، Att IDs Not In Master. كلها موجودة في _Measures، اسحبيها على بطاقة وخلاص.
الخطوة 7
النشر على SharePoint والتحديث اليومي
- بدّلي المسارات إلى روابط (الخطوة 1)في Transform data، خطوة Source في Employees وAttendance وContractor. بعدها Close & Apply وتأكدي إن الأرقام نفسها.
- Home ← Publish ← Workspace القسملو ما عندك Workspace، IT يسوي واحد باسم PAP Mechanical Maintenance ويعطيك Contributor.
- في Power BI Service: Semantic model ← Settings ← Data source credentialsلكل مصدر: Edit credentials ← OAuth2 ← حسابك في معادن. لا يحتاج Gateway لأن الملفات على SharePoint/OneDrive.
- Scheduled refresh: يوميًا 09:00 و 13:00الإدخال ينتهي 8 صباحًا، فالساعة 9 يكون داي اليوم ونايت أمس مسجلين.
- صفحة SharePoint ← Edit ← + ← Power BI web part ← الصقي رابط التقريرهذا اللي تفتحه الإدارة. الصلاحية على التقرير تُعطى من Power BI (Share ← People in your organization with the link) أو كـ App.
بعد النشر
الروتين اليومي والفخاخ
| متى | مين | وش يسوي |
|---|---|---|
| 7:00 إلى 8:00 | مسؤول الحضور | يسجل داي اليوم ويؤكد نايت أمس في ملف الماكرو، ويحفظ |
| 9:00 | Power BI | يتحدث لحاله. الإدارة تفتح صفحة SharePoint |
| 9:15 | أنتي | "Not recorded" و"Active but no record" ← رسالة للمسؤول |
| كل أحد | أنتي | احفظي MasterSheet الجديد من السويدي فوق القديم ← Refresh ← أضيفي الجدد للماستر |
| كل شهر | أنتي | من ملف الماستر: الهويات اللي تنتهي خلال 60 يوم ← رسالة للمشرف |
| الفخ | وش يصير | الحل |
|---|---|---|
| رقم في الحضور مكتوب غلط (44323 بدل C-44323) | سجله ما ينربط بموظف ويختفي من الرسوم | صححيه في ملف الحضور، أو أضيفي الرقم للماستر |
| موظف بدون رقم معادن | لا ينربط حضوره ويطلع في "Active but no record" | خذي رقمه من المقاول أو من الباج |
| فلتر التاريخ فاضي | Coverage تقسم على كل الأيام المسجلة | هذا صحيح للأسبوع؛ لليوم اختاري يومًا واحدًا |
| Fire Watch / Fabricator / Welder بدون حضور | ما يظهرون في "Active but no record" (مستثنون بالفلتر) | لو تغير الاستثناء عدلي فلتر الجدول |
| تغيير اسم الملف أو الجدول | Refresh يفشل | لا تغيرين الأسماء بعد النشر أبدًا |
آخر تحديث 15 سبتمبر 2026 · المصدر: 02_Attendance\5_Scripts\att_workforce_pbip.py