File size: 12,333 Bytes
d377501
c417ee2
9d7e9ab
 
d377501
8d5468b
 
9d7e9ab
 
 
8c3c990
9d7e9ab
7ce6d7e
 
 
 
 
 
8d5468b
 
7ce6d7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
911ed46
7ce6d7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22b6207
 
 
7ce6d7e
22b6207
7ce6d7e
911ed46
7070ab1
 
911ed46
7070ab1
 
 
911ed46
 
 
 
 
 
 
7070ab1
911ed46
7070ab1
911ed46
 
7ce6d7e
 
 
 
 
7070ab1
7ce6d7e
c417ee2
7ce6d7e
67636ef
 
aed76bf
7ce6d7e
 
 
9d7e9ab
7ce6d7e
 
aed76bf
7ce6d7e
7070ab1
7ce6d7e
7070ab1
9d7e9ab
7ce6d7e
 
9d7e9ab
22b6207
 
9d7e9ab
911ed46
 
 
7ce6d7e
22b6207
9d7e9ab
22b6207
9d7e9ab
 
ad93b65
c417ee2
7ce6d7e
 
911ed46
 
 
ad93b65
911ed46
7070ab1
911ed46
7ce6d7e
 
 
6df7d01
ad93b65
8c3c990
240a5ed
ad93b65
5c9a567
9b2b9da
240a5ed
7ce6d7e
627977a
7ce6d7e
ad93b65
240a5ed
 
7ce6d7e
9b2b9da
7ce6d7e
627977a
7ce6d7e
ad93b65
240a5ed
 
7ce6d7e
9a42b78
26a9b8b
aed76bf
ad93b65
999dac4
ad93b65
999dac4
 
ad93b65
911ed46
ad93b65
 
 
 
 
 
 
 
 
22b6207
 
 
 
c593638
22b6207
ad93b65
 
 
6df7d01
ad93b65
 
 
 
 
 
 
d377501
7ce6d7e
 
 
 
22b6207
7070ab1
22b6207
 
 
7ce6d7e
9d7e9ab
7ce6d7e
 
22b6207
7070ab1
22b6207
 
 
aed76bf
7ce6d7e
 
 
 
ad93b65
 
7ce6d7e
ad93b65
 
22b6207
 
 
7ce6d7e
d377501
c417ee2
7ce6d7e
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358

# Configuration
#SERVICE_ACCOUNT_FILE = 'digital-pagoda-477703-p7-37d35e143508.json'
#SCOPES = ['https://www.googleapis.com/auth/drive.readonly']



import json
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.http import MediaIoBaseDownload

import gradio as gr
import pandas as pd
from openpyxl import Workbook
from openpyxl.styles import PatternFill, Font
from datetime import datetime, timedelta
import io
import tempfile
import os

# ── Utility: Normalize and parse time strings
def fix_time_format(val):
    if pd.isna(val):
        return None
    s = str(val).strip()
    s = s.zfill(4)                # '800' -> '0800'
    return f"{s[:2]}:{s[2:]}"  # '0800' -> '08:00'

def generate_schedule(clients_df, techs_df):
    """Generate schedule based on clients and technicians data"""
    
    # Clean column names
    clients_df.columns = clients_df.columns.str.strip().str.title()
    techs_df.columns = techs_df.columns.str.strip().str.title()

    # Apply time fix and parse datetimes
    for df, cols in [(clients_df, ["Start","End","Nap Time"]), (techs_df, ["Start","End"])]:
        for c in cols:
            if c in df.columns:
                df[c] = df[c].apply(fix_time_format)
                df[c] = pd.to_datetime(df[c], format="%H:%M", errors="coerce")

    # Determine day range
    day_start = min(clients_df["Start"].min(), techs_df["Start"].min())
    day_end = max(clients_df["End"].max(), techs_df["End"].max())
    hours = []
    t = day_start.replace(minute=0)
    while t < day_end:
        hours.append(t)
        t += timedelta(hours=1)

    # Prepare tracking
    schedule = {c: {} for c in clients_df["Name"]}
    tech_busy = {t: set() for t in techs_df["Name"]}
    tech_client_hours = pd.DataFrame(0, index=techs_df["Name"], columns=clients_df["Name"])

    # Assign per hour
    for i, hour in enumerate(hours):
        for _, c in clients_df.iterrows():
            cname = c["Name"]
            if pd.isna(c["Start"]) or pd.isna(c["End"]) or not (c["Start"] <= hour < c["End"]):
                continue

            # Nap time
            nt = c.get("Nap Time")
            if pd.notna(nt) and nt <= hour < nt + timedelta(hours=1):
                schedule[cname][hour] = "NAP"
                continue

            if hour in schedule[cname]:
                continue

            # Build eligible techs
            candidates = []
            for _, t in techs_df.iterrows():
                tname = t["Name"]

                # Check availability
                if pd.isna(t["Start"]) or pd.isna(t["End"]) or not (t["Start"] <= hour < t["End"]):
                    continue
                if c["Gender"] == "F" and t["Gender"] == "M":
                    continue
                if hour in tech_busy[tname]:
                    continue
                if tech_client_hours.at[tname, cname] >= 4:
                    continue

                # Look ahead to find consecutive availability (up to 4 hours max with this client)
                consec = 0
                for j in range(i, min(i+4, len(hours))):
                    next_hour = hours[j]
                    if not (c["Start"] <= next_hour < c["End"]):
                        break
                    if pd.notna(nt) and nt <= next_hour < nt + timedelta(hours=1):
                        break
                    if next_hour >= t["End"] or next_hour in tech_busy[tname]:
                        break
                    if tech_client_hours.at[tname, cname] + consec >= 4:
                        break
                    consec += 1

                # Ignore if tech is only available for 1 hour
                if consec < 2:
                    continue

                prefs = [str(t.get(f"P{i}", "")).strip() for i in (1,2,3,4)]
                if cname in prefs:
                    score = prefs.index(cname) + 1
                elif "Any" in prefs:
                    score = 5
                else:
                    continue
                # Lower score is better; longer block is better β†’ sort by (score, -consec)
                candidates.append((score, -consec, tname))

            if not candidates:
                schedule[cname][hour] = ""
            else:
                candidates.sort()
                _, _, chosen = candidates[0]

                # Re-check how many hours we can assign now (again)
                block = 0
                for j in range(i, min(i+4, len(hours))):
                    next_hour = hours[j]
                    if not (c["Start"] <= next_hour < c["End"]):
                        break
                    if pd.notna(nt) and nt <= next_hour < nt + timedelta(hours=1):
                        break
                    if next_hour >= techs_df.loc[techs_df["Name"] == chosen, "End"].values[0]:
                        break
                    if next_hour in tech_busy[chosen]:
                        break
                    if tech_client_hours.at[chosen, cname] >= 4:
                        break
                    schedule[cname][next_hour] = chosen
                    tech_busy[chosen].add(next_hour)
                    tech_client_hours.at[chosen, cname] += 1
                    block += 1

    return hours, schedule, tech_client_hours

def create_excel_file(hours, schedule, techs_df):
    """Create Excel file with schedule"""
    wb = Workbook()
    ws = wb.active
    ws.title = "Schedule"

    # Header row
    ws.cell(1,1,"Time")
    clients = list(schedule.keys())
    for col, name in enumerate(clients, start=2):
        cell = ws.cell(1, col, name)
        cell.font = Font(bold=True)
        cell.fill = PatternFill("solid", fgColor="C6EFCE")

    # Tech color mapping
    tech_colors = {}
    if "Colours" in techs_df.columns:
        for _, r in techs_df.iterrows():
            col = str(r["Colours"]).strip().lstrip("#").upper()
            if len(col) == 6:
                col = "FF" + col
            tech_colors[r["Name"]] = col

    # Nap style
    nap_fill = PatternFill("solid", fgColor="D9D9D9")

    # Fill schedule
    for row, hour in enumerate(hours, start=2):
        ws.cell(row,1, hour.strftime("%H:%M"))
        for col, cname in enumerate(clients, start=2):
            val = schedule[cname].get(hour, "")
            cell = ws.cell(row, col, val)
            if val == "NAP":
                cell.fill = nap_fill
            elif val in tech_colors:
                fg = tech_colors[val]
                cell.fill = PatternFill("solid", fgColor=fg)

    # Create a temporary file
    temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx")
    wb.save(temp_file.name)
    
    return temp_file.name

def schedule_to_dataframe(hours, schedule):
    """Convert schedule to pandas DataFrame for display with Time as first column"""
    # Create DataFrame with time as a regular column
    time_strings = [hour.strftime("%H:%M") for hour in hours]
    
    # Create dictionary for DataFrame
    data = {"Time": time_strings}
    
    # Add each client as a column
    for client_name in schedule.keys():
        client_schedule = []
        for hour in hours:
            value = schedule[client_name].get(hour, "")
            client_schedule.append(value)
        data[client_name] = client_schedule
    
    df = pd.DataFrame(data)
    return df

def process_schedule(techs_file, clients_file):
    """Main function to process the schedule"""
    
    # Check if files are uploaded
    if techs_file is None or clients_file is None:
        return "❌ Please upload both files: 'techs_new_01.csv' and 'clients_01.csv'", None, None
    
    try:
        # Read CSV files
        techs_df = pd.read_csv(techs_file)
        clients_df = pd.read_csv(clients_file)
        
        # Validate required columns
        required_client_cols = ["Name", "Start", "End", "Gender"]
        required_tech_cols = ["Name", "Start", "End", "Gender"]
        
        missing_client = [col for col in required_client_cols if col not in clients_df.columns]
        missing_tech = [col for col in required_tech_cols if col not in techs_df.columns]
        
        if missing_client:
            return f"❌ Missing columns in clients file: {', '.join(missing_client)}", None, None
        if missing_tech:
            return f"❌ Missing columns in techs file: {', '.join(missing_tech)}", None, None
        
        # Generate schedule
        hours, schedule, tech_client_hours = generate_schedule(clients_df, techs_df)
        
        # Create Excel file
        excel_file_path = create_excel_file(hours, schedule, techs_df)
        
        # Convert schedule to DataFrame for display
        schedule_df = schedule_to_dataframe(hours, schedule)
        
        # Create summary
        summary = f"βœ… Schedule Generated! Clients: {len(clients_df)}, Techs: {len(techs_df)}, Time: {hours[0].strftime('%H:%M')}-{hours[-1].strftime('%H:%M')}\nπŸ“₯ Click the arrow β†’ to download Excel file"
        
        return summary, schedule_df, excel_file_path
        
    except Exception as e:
        return f"❌ Error: {str(e)}", None, None

def clear_all():
    """Clear all inputs and outputs"""
    return (
        None,  # techs_file
        None,  # clients_file
        "βœ… Ready to upload files",  # output_text
        None,  # schedule_table
        None   # download_file
    )

# Create Gradio interface
with gr.Blocks(theme=gr.themes.Soft()) as demo:
    gr.Markdown("# πŸ—“οΈ AI Schedule Generator")
    gr.Markdown("Upload CSV files to generate schedule")
    
    with gr.Row():
        # File uploads side by side
        #gr.Column(scale=0.05)  # Left spacer
        with gr.Column(scale=0.4):
            
            techs_file = gr.File(
                label="Techs.csv",
                file_types=[".csv"],
                type="filepath",
                height=120
                
            )
        with gr.Column(scale=0.4):
            clients_file = gr.File(
                label="Clients.csv", 
                file_types=[".csv"],
                type="filepath",
                height=120
                
            )
        #gr.Column(scale=0.05)  # Right spacer
        
    with gr.Row():
        # Buttons side by side
        with gr.Column(scale=0.4):
            generate_btn = gr.Button("πŸš€ Generate Schedule", variant="primary", size="lg")
    with gr.Row():
        with gr.Column(scale=0.4):
            refresh_btn = gr.Button("πŸ”„ Clear All", variant="secondary", size="lg")
    
    # Status box - 2 rows maximum
    output_text = gr.Textbox(
        label="Status",
        lines=2,
        max_lines=2,
        interactive=False,
        show_copy_button=True
    )
    
    # Download file component with arrow - this works reliably
    download_file = gr.File(
        label="Download Excel Schedule",
        file_types=[".xlsx"],
        visible=False,
        interactive=True
    )
    
    # Schedule preview
    gr.Markdown("### πŸ“‹ AI Schedule Preview")
    schedule_table = gr.Dataframe(
        label="",
        headers=None,
        max_height=400,
        wrap=True,
        interactive=False
    )
    
    # Set up event handlers
    generate_btn.click(
        fn=process_schedule,
        inputs=[techs_file, clients_file],
        outputs=[output_text, schedule_table, download_file]
    ).then(
        # Make download file visible after generation
        lambda: gr.update(visible=True),
        outputs=[download_file]
    )
    
    refresh_btn.click(
        fn=clear_all,
        outputs=[techs_file, clients_file, output_text, schedule_table, download_file]
    ).then(
        # Hide download file after clear
        lambda: gr.update(visible=False),
        outputs=[download_file]
    )
    
    gr.Markdown("""
    ### πŸ“ Required CSV Formats:
    
    **clients_01.csv:**
    - `Name`, `Start`, `End`, `Gender`, `Nap Time` (optional)
    
    **techs_new_01.csv:**
    - `Name`, `Start`, `End`, `Gender`, `P1-P4` (preferences), `Colours` (optional)
    
    ### πŸ’‘ How to download:
    After generating the schedule, click the **arrow (β†’)** next to "Download Excel Schedule" to download the file.
    """)

if __name__ == "__main__":
    demo.launch(
        server_name="0.0.0.0",
        server_port=7860,
        share=False
    )