feat(pm): polish UI/UX, align drawer with warehouse pattern, and connect real API data
- Refactor TodayPage into dedicated single-project site supervisor dashboard - Mirror warehouse panel menu accordion pattern for project switching - Separate high-priority QC emergency banner from checklist filter chips - Add smooth scrolling to emergency section when tapping critical KPI card - Connect all dashboard data (projects, KPIs, checklists, upcoming tasks) to real API endpoints - Remove all hardcoded mock arrays and fallbacks - Add clean loading skeleton with spinner and empty states - Dynamically format user display name and role-based titles in header
This commit is contained in:
@@ -13,7 +13,7 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ApiError } from "../lib/api";
|
import { ApiError } from "../lib/api";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { visibleTabs } from "../lib/roles";
|
import { visibleTabs, getRoleTitle } from "../lib/roles";
|
||||||
|
|
||||||
export type Tone = "red" | "amber" | "green" | "sky" | "slate";
|
export type Tone = "red" | "amber" | "green" | "sky" | "slate";
|
||||||
|
|
||||||
@@ -69,28 +69,27 @@ export function PageHeader({
|
|||||||
}) {
|
}) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const fullName = user?.display_name || user?.username || "کاربر";
|
const fullName = user?.display_name || user?.username || "کاربر";
|
||||||
const username = user?.username || "-";
|
const role = getRoleTitle(user?.role, user?.role_label);
|
||||||
const role =
|
const userSub = user ? (
|
||||||
user?.role_label ||
|
<span className="inline-flex items-center gap-1.5 text-[11px] text-slate-500 font-normal">
|
||||||
((user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor"
|
<span className="font-semibold text-slate-700">{fullName}</span>
|
||||||
? "سرپرست پروژه"
|
<span className="text-slate-300">•</span>
|
||||||
: (user?.role as string) === "admin"
|
<span className="font-medium text-sky-600">{role}</span>
|
||||||
? "مدیر ارشد"
|
</span>
|
||||||
: "مدیر پروژه");
|
) : null;
|
||||||
const userSub = user ? `${fullName} | نام کاربری: ${username} | نقش: ${role}` : null;
|
|
||||||
const displaySub = sub !== undefined ? sub : userSub;
|
const displaySub = sub !== undefined ? sub : userSub;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 flex min-h-[56px] items-center gap-1.5 border-b border-slate-200 bg-white/95 px-2 py-1.5 backdrop-blur">
|
<header className="sticky top-0 z-20 flex min-h-[56px] items-center gap-1.5 border-b border-slate-200/80 bg-white/95 px-2.5 py-1.5 backdrop-blur shadow-xs">
|
||||||
{menu && <div className="flex shrink-0 items-center">{menu}</div>}
|
{menu && <div className="flex shrink-0 items-center">{menu}</div>}
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
aria-label="بازگشت"
|
aria-label="بازگشت"
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-slate-600 active:bg-slate-100"
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-600 active:bg-slate-100"
|
||||||
>
|
>
|
||||||
<ChevronRight size={22} />
|
<ChevronRight size={20} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1 px-1">
|
<div className="min-w-0 flex-1 px-1">
|
||||||
@@ -114,13 +113,26 @@ const ERP_URL =
|
|||||||
type PanelMenuItem =
|
type PanelMenuItem =
|
||||||
| { kind: "divider" }
|
| { kind: "divider" }
|
||||||
| { kind: "link"; label: string; href: string }
|
| { kind: "link"; label: string; href: string }
|
||||||
| { kind: "current"; label: string }
|
| {
|
||||||
|
kind: "current";
|
||||||
|
label: string;
|
||||||
|
children?: { label: string; active: boolean; onClick: () => void }[];
|
||||||
|
}
|
||||||
| { kind: "button"; label: string; onClick: () => void };
|
| { kind: "button"; label: string; onClick: () => void };
|
||||||
|
|
||||||
export function PanelsMenu() {
|
export function PanelsMenu({
|
||||||
|
projects,
|
||||||
|
activeProjectId,
|
||||||
|
onSelectProject,
|
||||||
|
}: {
|
||||||
|
projects?: { id?: string | number; op_project_id?: number; name: string }[];
|
||||||
|
activeProjectId?: string | number;
|
||||||
|
onSelectProject?: (id: string) => void;
|
||||||
|
} = {}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const clear = useAuthStore((s) => s.clear);
|
const clear = useAuthStore((s) => s.clear);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -194,7 +206,23 @@ export function PanelsMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasPm) {
|
if (hasPm) {
|
||||||
items.push({ kind: "current", label: "مدیریت پروژه" });
|
const projectChildren =
|
||||||
|
projects && projects.length > 0
|
||||||
|
? projects.map((p) => {
|
||||||
|
const pId = String(p.id ?? p.op_project_id ?? "");
|
||||||
|
return {
|
||||||
|
label: p.name,
|
||||||
|
active: String(activeProjectId) === pId,
|
||||||
|
onClick: () => onSelectProject?.(pId),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
kind: "current",
|
||||||
|
label: "مدیریت پروژه",
|
||||||
|
children: projectChildren,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canManageOrg) {
|
if (canManageOrg) {
|
||||||
@@ -211,7 +239,7 @@ export function PanelsMenu() {
|
|||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
aria-label="منو"
|
aria-label="منو"
|
||||||
title="منو"
|
title="منو"
|
||||||
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-600 active:bg-slate-100"
|
className="flex h-10 w-10 items-center justify-center rounded-xl text-slate-600 hover:bg-slate-100 active:scale-95 transition-all"
|
||||||
>
|
>
|
||||||
{open ? <X size={22} /> : <Menu size={22} />}
|
{open ? <X size={22} /> : <Menu size={22} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -240,12 +268,51 @@ export function PanelsMenu() {
|
|||||||
{it.label}
|
{it.label}
|
||||||
</a>
|
</a>
|
||||||
) : it.kind === "current" ? (
|
) : it.kind === "current" ? (
|
||||||
<div
|
<div key={i}>
|
||||||
key={i}
|
<div className="flex items-center justify-between rounded-lg bg-sky-600 px-3 py-2 text-[13px] font-bold text-white shadow-xs">
|
||||||
className="flex items-center justify-between gap-3 rounded-lg bg-sky-600 px-3 py-2 text-[13px] font-bold text-white"
|
<div className="flex items-center gap-2">
|
||||||
>
|
<span>{it.label}</span>
|
||||||
{it.label}
|
<span className="text-[10px] opacity-80">✓ فعلی</span>
|
||||||
<span className="text-[10px] opacity-80">✓ فعلی</span>
|
</div>
|
||||||
|
{it.children && it.children.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={expanded ? "بستن فهرست پروژهها" : "نمایش پروژهها"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded((ex) => !ex);
|
||||||
|
}}
|
||||||
|
className="inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-white hover:bg-white/15 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
size={15}
|
||||||
|
className={`transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{it.children && it.children.length > 0 && expanded && (
|
||||||
|
<div className="mt-1 flex flex-col gap-0.5 border-s-2 border-slate-200 ps-2 ms-3">
|
||||||
|
{it.children.map((c, j) => (
|
||||||
|
<button
|
||||||
|
key={j}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
c.onClick();
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-[12.5px] text-start transition-colors ${
|
||||||
|
c.active
|
||||||
|
? "bg-sky-50 font-bold text-sky-700"
|
||||||
|
: "text-slate-700 hover:bg-slate-50 active:bg-slate-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{c.label}</span>
|
||||||
|
{c.active && <span className="shrink-0 text-[10px] font-bold">✓ فعلی</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -275,23 +342,30 @@ export function TabBar() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const tabs = visibleTabs(user);
|
const tabs = visibleTabs(user);
|
||||||
return (
|
return (
|
||||||
<nav className="fixed bottom-0 left-1/2 z-30 w-full max-w-[420px] -translate-x-1/2 border-t border-slate-200 bg-white/95 pb-[env(safe-area-inset-bottom)] backdrop-blur">
|
<nav className="fixed bottom-0 left-1/2 z-30 w-full max-w-[420px] -translate-x-1/2 border-t border-slate-200/80 bg-white/95 pb-[env(safe-area-inset-bottom)] shadow-[0_-4px_16px_rgba(0,0,0,0.04)] backdrop-blur-md">
|
||||||
<div className="flex h-16 items-stretch">
|
<div className="flex h-14 items-center justify-around px-2">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={tab.to}
|
key={tab.to}
|
||||||
to={tab.to}
|
to={tab.to}
|
||||||
end={tab.end}
|
end={tab.end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors ${
|
`group relative flex flex-1 flex-col items-center justify-center py-1 transition-all duration-200 active:scale-95 ${
|
||||||
isActive ? "text-sky-600" : "text-slate-400"
|
isActive ? "text-sky-600 font-bold" : "text-slate-400 hover:text-slate-600 font-medium"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<tab.icon size={22} strokeWidth={isActive ? 2.3 : 1.8} />
|
{/* Soft active indicator background pill & glowing top indicator */}
|
||||||
<span>{tab.label}</span>
|
{isActive && (
|
||||||
|
<>
|
||||||
|
<span className="absolute inset-x-1.5 inset-y-0.5 -z-10 rounded-xl bg-sky-50 shadow-xs border border-sky-100/70" />
|
||||||
|
<span className="absolute -top-1.5 h-1 w-5 rounded-full bg-sky-600 shadow-[0_0_8px_rgba(2,132,199,0.55)]" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<tab.icon size={20} strokeWidth={isActive ? 2.4 : 1.7} className="transition-transform group-hover:scale-105" />
|
||||||
|
<span className="text-[10px] leading-tight mt-0.5">{tab.label}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -301,6 +375,71 @@ export function TabBar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------
|
||||||
|
// کنترل سگمنتد تبها (Segmented Control)
|
||||||
|
// ----------------------------------------
|
||||||
|
|
||||||
|
export interface SegmentedOption<T extends string = string> {
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
count?: number;
|
||||||
|
tone?: "red" | "amber" | "green" | "sky" | "slate";
|
||||||
|
icon?: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SegmentedControl<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
options: SegmentedOption<T>[];
|
||||||
|
value: T;
|
||||||
|
onChange: (val: T) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
className={`flex items-center rounded-xl bg-slate-100/90 p-1 text-xs font-medium text-slate-500 shadow-inner ${className}`}
|
||||||
|
>
|
||||||
|
{options.map((opt) => {
|
||||||
|
const active = opt.id === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => onChange(opt.id)}
|
||||||
|
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 text-center text-xs font-semibold transition-all duration-150 select-none ${
|
||||||
|
active
|
||||||
|
? "bg-white text-slate-900 shadow-sm border border-slate-200/60"
|
||||||
|
: "text-slate-500 hover:text-slate-800 active:bg-slate-200/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.icon && <opt.icon size={13} className={active ? "text-sky-600" : "text-slate-400"} />}
|
||||||
|
<span className="truncate">{opt.label}</span>
|
||||||
|
{opt.count !== undefined && opt.count > 0 && (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-1.5 py-0.2 text-[10px] font-bold tabular-nums leading-none ${
|
||||||
|
opt.tone === "red"
|
||||||
|
? active ? "bg-red-100 text-red-700" : "bg-red-50 text-red-600"
|
||||||
|
: opt.tone === "amber"
|
||||||
|
? active ? "bg-amber-100 text-amber-700" : "bg-amber-50 text-amber-600"
|
||||||
|
: active ? "bg-sky-100 text-sky-700" : "bg-slate-200 text-slate-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// کارت و چیپ
|
// کارت و چیپ
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
|
|||||||
@@ -209,8 +209,10 @@ export type DefectSeverity = "low" | "medium" | "high";
|
|||||||
|
|
||||||
export interface DashboardTask {
|
export interface DashboardTask {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
project_id?: string;
|
||||||
op_wp_id?: number | null;
|
op_wp_id?: number | null;
|
||||||
subject: string;
|
subject: string;
|
||||||
|
description?: string;
|
||||||
due_date: string | null;
|
due_date: string | null;
|
||||||
percentage_done: number;
|
percentage_done: number;
|
||||||
project_name: string;
|
project_name: string;
|
||||||
@@ -248,6 +250,9 @@ export interface DashboardToday {
|
|||||||
export interface ProjectStats {
|
export interface ProjectStats {
|
||||||
total: number;
|
total: number;
|
||||||
active: number;
|
active: number;
|
||||||
|
completed?: number;
|
||||||
|
in_progress?: number;
|
||||||
|
blocked?: number;
|
||||||
awaiting_qc: number;
|
awaiting_qc: number;
|
||||||
failed_qc: number;
|
failed_qc: number;
|
||||||
overdue: number;
|
overdue: number;
|
||||||
|
|||||||
@@ -34,9 +34,61 @@ export type Permission = (typeof Perm)[keyof typeof Perm];
|
|||||||
|
|
||||||
export const roleLabels: Record<Role, string> = {
|
export const roleLabels: Record<Role, string> = {
|
||||||
project_manager: "مدیر پروژه",
|
project_manager: "مدیر پروژه",
|
||||||
project_supervisor: "سرپرست پروژه",
|
project_supervisor: "سرپرست کارگاه",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نگاشت تمیز کدهای نقش به عناوین فارسی استاندارد سیستم:
|
||||||
|
* - site_supervisor / project_supervisor / supervisor -> "سرپرست کارگاه"
|
||||||
|
* - project_manager / pm -> "مدیر پروژه"
|
||||||
|
* - admin / ceo -> "مدیر ارشد"
|
||||||
|
*/
|
||||||
|
export function getRoleTitle(role?: string | null, customLabel?: string | null): string {
|
||||||
|
const normalized = (role ?? "").toLowerCase().trim();
|
||||||
|
switch (normalized) {
|
||||||
|
case "site_supervisor":
|
||||||
|
case "project_supervisor":
|
||||||
|
case "supervisor":
|
||||||
|
return "سرپرست کارگاه";
|
||||||
|
case "project_manager":
|
||||||
|
case "pm":
|
||||||
|
return "مدیر پروژه";
|
||||||
|
case "admin":
|
||||||
|
case "ceo":
|
||||||
|
case "administrator":
|
||||||
|
return "مدیر ارشد";
|
||||||
|
case "procurement_manager":
|
||||||
|
return "مدیر تدارکات";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customLabel && customLabel.trim()) {
|
||||||
|
if (customLabel.trim() === "سرپرست پروژه") {
|
||||||
|
return "سرپرست کارگاه";
|
||||||
|
}
|
||||||
|
return customLabel.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!role) return "کاربر سیستم";
|
||||||
|
return roleLabels[normalized as Role] || "کاربر سیستم";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تعیین عنوان پویای بالای هدر صفحه بر اساس نقش کاربر:
|
||||||
|
* - سرپرست کارگاه -> "میز کار سرپرست کارگاه"
|
||||||
|
* - مدیر پروژه / سایر -> "مدیریت پروژه"
|
||||||
|
*/
|
||||||
|
export function getDashboardHeaderTitle(role?: string | null): string {
|
||||||
|
const normalized = (role ?? "").toLowerCase().trim();
|
||||||
|
if (
|
||||||
|
normalized === "site_supervisor" ||
|
||||||
|
normalized === "project_supervisor" ||
|
||||||
|
normalized === "supervisor"
|
||||||
|
) {
|
||||||
|
return "میز کار سرپرست کارگاه";
|
||||||
|
}
|
||||||
|
return "مدیریت پروژه";
|
||||||
|
}
|
||||||
|
|
||||||
export function hasPermission(user: User | null | undefined, permission: Permission | string): boolean {
|
export function hasPermission(user: User | null | undefined, permission: Permission | string): boolean {
|
||||||
return user?.permissions?.includes(permission) ?? false;
|
return user?.permissions?.includes(permission) ?? false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Folder, Plus, RefreshCw, Users, MapPin, Calendar } from "lucide-react";
|
import { Folder, Plus, RefreshCw, ChevronLeft } from "lucide-react";
|
||||||
import { createProject, getProjects } from "../lib/api";
|
import { createProject, getProjects } from "../lib/api";
|
||||||
import type { CreateProjectPayload, Project } from "../lib/api";
|
import type { CreateProjectPayload, Project } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PanelsMenu,
|
PanelsMenu,
|
||||||
ProgressBar,
|
|
||||||
btnPrimary,
|
btnPrimary,
|
||||||
btnSecondary,
|
btnSecondary,
|
||||||
inputCls,
|
inputCls,
|
||||||
@@ -34,73 +33,84 @@ function ProjectCard({ project }: { project: Project }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentStatus = project.status ? statusLabels[project.status.toUpperCase()] : null;
|
const currentStatus = project.status ? statusLabels[project.status.toUpperCase()] : null;
|
||||||
|
const progressVal =
|
||||||
|
project.progress !== undefined
|
||||||
|
? Math.round(project.progress)
|
||||||
|
: project.stats.total > 0
|
||||||
|
? Math.round(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
((project.stats.total - project.stats.overdue - project.stats.failed_qc) /
|
||||||
|
project.stats.total) *
|
||||||
|
100
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`/projects/${targetId}`} className="block">
|
<Link to={`/projects/${targetId}`} className="group block">
|
||||||
<Card className="p-4 active:bg-slate-100/60 transition-all hover:border-slate-300">
|
<Card className="p-3 shadow-xs transition-all duration-150 hover:border-slate-300 hover:shadow-sm active:bg-slate-50/90">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
{/* بلوک محتوایی ۲ سطره در سمت راست */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<p className="min-w-0 truncate text-sm font-bold text-slate-800">{project.name}</p>
|
{/* سطر ۱: نام پروژه و بج پیل وضعیت */}
|
||||||
{currentStatus && <Chip tone={currentStatus.tone}>{currentStatus.label}</Chip>}
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
</div>
|
<p className="min-w-0 truncate text-xs font-bold text-slate-800 group-hover:text-sky-700 transition-colors">
|
||||||
<p dir="ltr" className="mt-0.5 text-right font-mono text-[11px] text-slate-400">
|
{project.name}
|
||||||
{project.identifier}
|
</p>
|
||||||
</p>
|
{project.stats.failed_qc > 0 ? (
|
||||||
</div>
|
<span className="inline-flex shrink-0 items-center rounded-full bg-red-50 border border-red-200/60 px-2 py-0.5 text-[10px] font-bold text-red-600">
|
||||||
</div>
|
{project.stats.failed_qc} مردود
|
||||||
|
|
||||||
{project.location && (
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<MapPin size={13} className="text-slate-400" />
|
|
||||||
{project.location}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(project.planned_start_jalali || project.planned_end_jalali || project.actual_start_jalali) && (
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
|
|
||||||
{(project.planned_start_jalali || project.planned_end_jalali) && (
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<Calendar size={12} className="text-slate-400" />
|
|
||||||
<span>برنامه:</span>
|
|
||||||
<span className="font-medium text-slate-700">
|
|
||||||
{project.planned_start_jalali || "—"} تا {project.planned_end_jalali || "—"}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
) : currentStatus ? (
|
||||||
)}
|
<Chip tone={currentStatus.tone} className="text-[10px] py-0 px-2 shrink-0">
|
||||||
{project.actual_start_jalali && (
|
{currentStatus.label}
|
||||||
<span className="inline-flex items-center gap-1 rounded bg-emerald-50 px-1.5 py-0.5 text-emerald-700">
|
</Chip>
|
||||||
<span>واقعی:</span>
|
) : (
|
||||||
<span>{project.actual_start_jalali}</span>
|
<span className="inline-flex shrink-0 items-center rounded-full bg-emerald-50 border border-emerald-200/60 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||||
{project.actual_end_jalali && <span> تا {project.actual_end_jalali}</span>}
|
در حال اجرا
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{project.progress !== undefined && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<div className="flex items-center justify-between text-[11px] text-slate-500 mb-1">
|
|
||||||
<span>پیشرفت پروژه</span>
|
|
||||||
<span className="font-bold tabular-nums text-slate-700">{Math.round(project.progress)}٪</span>
|
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar value={project.progress} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
{/* سطر ۲: پیل تعداد فعالیت در راست — نوار پیشرفت باریک با درصد در چپ */}
|
||||||
<Chip tone="sky">کل: {project.stats.total}</Chip>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Chip tone="amber">انتظار QC: {project.stats.awaiting_qc}</Chip>
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
{project.stats.failed_qc > 0 && <Chip tone="red">مردود: {project.stats.failed_qc}</Chip>}
|
<span className="inline-flex shrink-0 items-center rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||||
{project.stats.overdue > 0 && <Chip tone="red">عقبافتاده: {project.stats.overdue}</Chip>}
|
{project.stats.total} فعالیت
|
||||||
{project.member_count !== undefined && project.member_count > 0 && (
|
</span>
|
||||||
<span className="ms-auto inline-flex items-center gap-1 text-[11px] text-slate-400">
|
{project.stats.awaiting_qc > 0 && (
|
||||||
<Users size={12} />
|
<span className="inline-flex shrink-0 items-center rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
||||||
{project.member_count} عضو
|
{project.stats.awaiting_qc} منتظر QC
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{project.location && (
|
||||||
|
<span className="truncate text-[10px] text-slate-400 hidden sm:inline">
|
||||||
|
• {project.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 w-28 sm:w-32 shrink-0 justify-end">
|
||||||
|
<div dir="rtl" className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-300 ${
|
||||||
|
progressVal >= 100 ? "bg-emerald-500" : "bg-sky-500"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${progressVal}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-left text-[10px] font-bold tabular-nums text-slate-500 w-7">
|
||||||
|
{progressVal}٪
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* فلش تعاملی در منتهیالیه چپ با تراز عمودی کامل روی کل ارتفاع کارت */}
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center self-center text-slate-400 transition-all group-hover:text-sky-600 group-hover:bg-sky-50 group-active:-translate-x-0.5 rounded-lg">
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
+572
-195
@@ -1,103 +1,244 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo, useRef, useEffect } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { CheckCircle2, ChevronDown, ChevronUp, Folder, RefreshCw } from "lucide-react";
|
import {
|
||||||
import { dashboardTaskKey, devLogin, getDashboardToday, getDevRoles } from "../lib/api";
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronUp,
|
||||||
|
ClipboardCheck,
|
||||||
|
RefreshCw,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { dashboardTaskKey, devLogin, getDashboardToday, getDevRoles, getProjects } from "../lib/api";
|
||||||
import type { DashboardTask, Project } from "../lib/api";
|
import type { DashboardTask, Project } from "../lib/api";
|
||||||
import { Card, CardSkeleton, Chip, EmptyState, ErrorState, PageHeader, PanelsMenu, ProgressBar } from "../components/ui";
|
import {
|
||||||
import { formatDate, isOverdue } from "../lib/format";
|
Card,
|
||||||
import { roleLabels, scopeLabel } from "../lib/roles";
|
CardSkeleton,
|
||||||
|
Chip,
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
PageHeader,
|
||||||
|
PanelsMenu,
|
||||||
|
type SegmentedOption,
|
||||||
|
} from "../components/ui";
|
||||||
|
import { formatDate } from "../lib/format";
|
||||||
|
import { getDashboardHeaderTitle, getRoleTitle } from "../lib/roles";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
|
||||||
type SectionKey = "overdue" | "due_today" | "awaiting_qc" | "failed_qc" | "blocked" | "upcoming";
|
type FieldTab = "all" | "pending_approval" | "in_progress";
|
||||||
|
|
||||||
interface SectionMeta {
|
/** کارت اکشن فوری برای موارد QC مردود (طرح اضطراری با دکمه تمامعرض) */
|
||||||
key: SectionKey;
|
function QcFailedEmergencyCard({
|
||||||
title: string;
|
task,
|
||||||
tone: "red" | "amber" | "sky" | "slate";
|
projectName,
|
||||||
}
|
}: {
|
||||||
|
task: DashboardTask;
|
||||||
/**
|
projectName: string;
|
||||||
* بخشهای داشبورد به تفکیک نقش:
|
}) {
|
||||||
* مدیر پروژه نگاه مدیریتی دارد (تأخیرها و QC همه پروژهها) و سرپرست پروژه نگاه
|
|
||||||
* عملیاتی (کار امروز و کاری که باید همین حالا اجرا/بازرسی شود).
|
|
||||||
*/
|
|
||||||
const managerSections: SectionMeta[] = [
|
|
||||||
{ key: "overdue", title: "عقبافتاده", tone: "red" },
|
|
||||||
{ key: "due_today", title: "کارهای امروز", tone: "sky" },
|
|
||||||
{ key: "awaiting_qc", title: "منتظر QC", tone: "amber" },
|
|
||||||
{ key: "failed_qc", title: "QC مردود", tone: "red" },
|
|
||||||
{ key: "blocked", title: "مسدود شده", tone: "slate" },
|
|
||||||
{ key: "upcoming", title: "هفته آینده", tone: "sky" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const supervisorSections: SectionMeta[] = [
|
|
||||||
{ key: "due_today", title: "کارهای امروز", tone: "sky" },
|
|
||||||
{ key: "overdue", title: "عقبافتاده", tone: "red" },
|
|
||||||
{ key: "awaiting_qc", title: "منتظر اجرای بازرسی", tone: "amber" },
|
|
||||||
{ key: "failed_qc", title: "بازرسی مردود", tone: "red" },
|
|
||||||
{ key: "blocked", title: "مسدود شده (پیشنیاز ناتمام)", tone: "slate" },
|
|
||||||
{ key: "upcoming", title: "هفته آینده", tone: "sky" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function TaskRow({ task, showBlockedBy = false }: { task: DashboardTask; showBlockedBy?: boolean }) {
|
|
||||||
const overdue = isOverdue(task.due_date);
|
|
||||||
// شناسهٔ پایدار: UUID تسک بومی، وگرنه op_wp_id میراث
|
|
||||||
const taskKey = dashboardTaskKey(task);
|
const taskKey = dashboardTaskKey(task);
|
||||||
return (
|
return (
|
||||||
<Link to={`/tasks/${taskKey}`} className="block py-3">
|
<div className="relative overflow-hidden rounded-xl border border-red-200/90 bg-gradient-to-l from-red-50/70 via-white to-white p-3.5 shadow-xs border-r-4 border-r-red-600 transition-all hover:border-red-300">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="min-w-0 flex-1 text-[13px] font-medium leading-5 text-slate-800">{task.subject}</p>
|
<h3 className="text-xs sm:text-[13px] font-bold text-slate-800 leading-snug min-w-0 flex-1">
|
||||||
<span className={`shrink-0 text-[11px] ${overdue ? "font-semibold text-red-600" : "text-slate-400"}`}>
|
{task.subject}
|
||||||
{formatDate(task.due_date)}
|
</h3>
|
||||||
|
<span className="shrink-0 rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-bold text-red-700 border border-red-200/60">
|
||||||
|
عدم انطباق بحرانی
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<ProgressBar value={task.percentage_done} className="flex-1" />
|
|
||||||
<span className="w-9 shrink-0 text-left text-[10px] text-slate-400">{task.percentage_done}٪</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-slate-400">
|
|
||||||
<span className="truncate">{task.project_name}</span>
|
|
||||||
{showBlockedBy && task.blocked_by && (
|
|
||||||
<span className="shrink-0 truncate text-slate-500">مسدود توسط: {task.blocked_by}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryBox({ tone, label, value }: { tone: "red" | "amber" | "green"; label: string; value: number }) {
|
<div className="mt-2.5 grid grid-cols-3 gap-2 rounded-lg bg-slate-50/90 p-2 text-xs border border-slate-100">
|
||||||
const cls = {
|
<div className="min-w-0">
|
||||||
red: "border-red-100 bg-red-50 text-red-600",
|
<span className="block text-[10px] text-slate-400">پروژه فعال</span>
|
||||||
amber: "border-amber-100 bg-amber-50 text-amber-600",
|
<span className="truncate font-semibold text-slate-700 text-[11px] block mt-0.5">
|
||||||
green: "border-emerald-100 bg-emerald-50 text-emerald-600",
|
{projectName}
|
||||||
}[tone];
|
</span>
|
||||||
return (
|
</div>
|
||||||
<div className={`rounded-2xl border p-3 text-center ${cls}`}>
|
<div className="min-w-0 text-center">
|
||||||
<p className="text-xl font-extrabold">{value}</p>
|
<span className="block text-[10px] text-slate-400">تاریخ بازرسی</span>
|
||||||
<p className="mt-0.5 text-[11px] font-medium opacity-80">{label}</p>
|
<span className="font-semibold text-red-600 text-[11px] block mt-0.5 tabular-nums">
|
||||||
|
{formatDate(task.due_date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-left">
|
||||||
|
<span className="block text-[10px] text-slate-400">پیشرفت فعلی</span>
|
||||||
|
<span className="font-bold text-slate-700 text-[11px] block mt-0.5 tabular-nums">
|
||||||
|
{task.percentage_done}٪
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<Link
|
||||||
|
to={`/tasks/${taskKey}`}
|
||||||
|
className="inline-flex w-full items-center justify-center gap-1.5 rounded-xl bg-red-600 py-2.5 text-xs font-bold text-white shadow-xs transition-all hover:bg-red-700 active:scale-[0.99]"
|
||||||
|
>
|
||||||
|
<span>مشاهده عدم انطباق و ثبت اقدام اصلاحی</span>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** کاشی مدیریتی یک پروژه — فقط در داشبورد مدیر پروژه */
|
/** ردیف فشرده و مدرن چکلیست بازرسی روزانه (ارتفاع ۵۶ تا ۶۴ پیکسل) */
|
||||||
function ProjectTile({ project }: { project: Project }) {
|
function TodayChecklistRow({
|
||||||
|
task,
|
||||||
|
projectName,
|
||||||
|
phase,
|
||||||
|
}: {
|
||||||
|
task: DashboardTask;
|
||||||
|
projectName: string;
|
||||||
|
phase?: string;
|
||||||
|
}) {
|
||||||
|
const taskKey = dashboardTaskKey(task);
|
||||||
|
|
||||||
|
const statusMeta =
|
||||||
|
task.qc_status === "pre_qc_pending" || task.qc_status === "exec_qc_pending"
|
||||||
|
? { label: "در انتظار بازرسی", bg: "bg-amber-50 text-amber-700 border-amber-200/60" }
|
||||||
|
: task.qc_status === "ready"
|
||||||
|
? { label: "آماده اجرا / تایید", bg: "bg-sky-50 text-sky-700 border-sky-200/60" }
|
||||||
|
: task.qc_status === "in_progress"
|
||||||
|
? { label: "در حال بررسی", bg: "bg-slate-100 text-slate-700 border-slate-200" }
|
||||||
|
: task.qc_status === "final_passed"
|
||||||
|
? { label: "تأیید نهایی", bg: "bg-emerald-50 text-emerald-700 border-emerald-200/60" }
|
||||||
|
: { label: "آماده اقدام", bg: "bg-sky-50 text-sky-700 border-sky-200/60" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`/projects/${project.id || project.op_project_id}`} className="block">
|
<div className="flex min-h-[60px] items-center justify-between gap-3 px-3.5 py-2.5 bg-white transition-colors hover:bg-slate-50/80">
|
||||||
<div className="rounded-xl border border-slate-200 p-3 active:bg-slate-50">
|
{/* سمت راست: آیکون وضعیت + عنوان چکلیست (با شکست تا ۲ خط بدون truncate سخت) و مرحله */}
|
||||||
<p className="truncate text-xs font-bold text-slate-800">{project.name}</p>
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sky-50 text-sky-600 border border-sky-100 self-center">
|
||||||
<Chip tone="sky">{project.stats.total} فعالیت</Chip>
|
<ClipboardCheck size={16} />
|
||||||
{project.stats.overdue > 0 && <Chip tone="red">{project.stats.overdue} عقبافتاده</Chip>}
|
</div>
|
||||||
{project.stats.awaiting_qc > 0 && <Chip tone="amber">{project.stats.awaiting_qc} منتظر QC</Chip>}
|
<div className="min-w-0 flex-1">
|
||||||
{project.stats.failed_qc > 0 && <Chip tone="red">{project.stats.failed_qc} مردود</Chip>}
|
<p className="line-clamp-2 text-xs font-bold text-slate-800 leading-snug break-words">
|
||||||
|
{task.subject}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-slate-400 mt-1 truncate">
|
||||||
|
<span className="font-medium text-slate-500 truncate">{projectName}</span>
|
||||||
|
{phase && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="truncate">{phase}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* سمت چپ: پیل فشرده وضعیت + دکمه آیکونی کوچک برای بازکردن بازرسی (تراز عمودی مرکز) */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0 self-center">
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-bold border ${statusMeta.bg}`}>
|
||||||
|
{statusMeta.label}
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
to={`/tasks/${taskKey}`}
|
||||||
|
aria-label="بازکردن بازرسی"
|
||||||
|
title="بازکردن فرم بازرسی"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 active:scale-95 transition-all"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** کارت پیشنمایش وظایف و نقاط عطف هفته آینده */
|
||||||
|
function UpcomingTaskCard({
|
||||||
|
task,
|
||||||
|
projectName,
|
||||||
|
}: {
|
||||||
|
task: DashboardTask;
|
||||||
|
projectName: string;
|
||||||
|
}) {
|
||||||
|
const taskKey = dashboardTaskKey(task);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/tasks/${taskKey}`}
|
||||||
|
className="group block rounded-xl border border-slate-200/80 bg-white p-3 shadow-xs transition-all duration-150 hover:border-sky-300 hover:shadow-sm active:bg-slate-50/80"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<p className="truncate text-xs font-bold text-slate-800 group-hover:text-sky-700 transition-colors">
|
||||||
|
{task.subject}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 text-[10px]">
|
||||||
|
<span className="inline-flex items-center rounded-md bg-slate-100 px-2 py-0.5 font-medium text-slate-600 truncate max-w-[150px]">
|
||||||
|
{projectName}
|
||||||
|
</span>
|
||||||
|
{task.due_date && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-md bg-sky-50 border border-sky-200/60 px-2 py-0.5 font-semibold text-sky-700 tabular-nums">
|
||||||
|
<Calendar size={11} className="text-sky-600" />
|
||||||
|
<span>موعد: {formatDate(task.due_date)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.percentage_done > 0 && (
|
||||||
|
<span className="inline-flex items-center rounded-md bg-slate-50 px-1.5 py-0.5 text-slate-400 tabular-nums">
|
||||||
|
{task.percentage_done}٪
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center self-center text-slate-400 transition-all group-hover:text-sky-600 group-hover:bg-sky-50 group-active:-translate-x-0.5 rounded-lg">
|
||||||
|
<ChevronLeft size={18} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** انتخابگر نقش — فقط در فاز توسعه (سرور آن را با PM_DEV_ROLE_SWITCH کنترل میکند) */
|
/** کارت شاخص آماری با عناوین کوتاه و بدون بریدگی متن */
|
||||||
|
function SummaryBox({
|
||||||
|
tone,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
tone: "red" | "amber" | "green";
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
const cls = {
|
||||||
|
red: "border-red-200/80 bg-red-50/60 text-red-700 hover:border-red-300",
|
||||||
|
amber: "border-amber-200/80 bg-amber-50/60 text-amber-700 hover:border-amber-300",
|
||||||
|
green: "border-emerald-200/80 bg-emerald-50/60 text-emerald-700 hover:border-emerald-300",
|
||||||
|
}[tone];
|
||||||
|
|
||||||
|
const iconCls = {
|
||||||
|
red: "text-red-500",
|
||||||
|
amber: "text-amber-500",
|
||||||
|
green: "text-emerald-500",
|
||||||
|
}[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-xl border p-2 text-center transition-all duration-150 active:scale-95 ${cls} hover:shadow-xs`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
|
{Icon && <Icon size={16} className={`${iconCls} shrink-0 opacity-90`} />}
|
||||||
|
<span className="text-lg font-black tabular-nums">{value}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs font-bold leading-tight truncate">{label}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** انتخابگر نقش در حالت توسعه */
|
||||||
function DevRoleSwitcher() {
|
function DevRoleSwitcher() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const setAuth = useAuthStore((s) => s.setAuth);
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
@@ -140,7 +281,7 @@ function DevRoleSwitcher() {
|
|||||||
>
|
>
|
||||||
{roles.map((r) => (
|
{roles.map((r) => (
|
||||||
<option key={r.username} value={r.username}>
|
<option key={r.username} value={r.username}>
|
||||||
{roleLabels[r.role]} — {r.all_projects ? "همه پروژهها" : `${r.project_count} پروژه`}
|
{getRoleTitle(r.role)} — {r.all_projects ? "همه پروژهها" : `${r.project_count} پروژه`}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -150,28 +291,170 @@ function DevRoleSwitcher() {
|
|||||||
export default function TodayPage() {
|
export default function TodayPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const [showUpcoming, setShowUpcoming] = useState(false);
|
|
||||||
|
// تب فعال عملیاتی چکلیستهای روزانه: همه اقدامات | در انتظار تایید / بازرسی | در حال بررسی
|
||||||
|
const [activeTab, setActiveTab] = useState<FieldTab>("all");
|
||||||
|
const [showUpcoming, setShowUpcoming] = useState<boolean | null>(null);
|
||||||
|
const emergencyRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleCriticalClick = () => {
|
||||||
|
if (emergencyRef.current) {
|
||||||
|
emergencyRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// واکشی دادههای داشبورد امروز و لیست پروژهها از اندپوینتهای واقعی
|
||||||
const todayQuery = useQuery({ queryKey: ["today"], queryFn: getDashboardToday });
|
const todayQuery = useQuery({ queryKey: ["today"], queryFn: getDashboardToday });
|
||||||
|
const projectsQuery = useQuery({ queryKey: ["projects"], queryFn: getProjects });
|
||||||
const data = todayQuery.data;
|
const data = todayQuery.data;
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["today"] });
|
void queryClient.invalidateQueries({ queryKey: ["today"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const isManager = user?.role === "project_manager";
|
// لیست کلی پروژهها مستقیماً از دادههای API
|
||||||
const role = data?.role ?? user?.role ?? "project_supervisor";
|
const allProjects = useMemo(() => {
|
||||||
const sections = isManager ? managerSections : supervisorSections;
|
return projectsQuery.data ?? data?.projects ?? [];
|
||||||
const projects = data?.projects ?? [];
|
}, [projectsQuery.data, data?.projects]);
|
||||||
|
|
||||||
const totalItems = data
|
// مدیریت شناسه پروژه فعال با ذخیرهسازی محلی
|
||||||
? Object.values(data.sections).reduce((sum, list) => sum + list.length, 0)
|
const [activeProjectId, setActiveProjectId] = useState<string>(() => {
|
||||||
: 0;
|
return localStorage.getItem("pm_active_project_id") || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeProject = useMemo(() => {
|
||||||
|
if (allProjects.length === 0) return null;
|
||||||
|
const found = allProjects.find(
|
||||||
|
(p) => String(p.id || (p as Project).op_project_id) === String(activeProjectId)
|
||||||
|
);
|
||||||
|
return found || allProjects[0];
|
||||||
|
}, [allProjects, activeProjectId]);
|
||||||
|
|
||||||
|
// همگامسازی خودکار شناسه در صورت نامعتبر بودن مقدار محلی
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeProject) {
|
||||||
|
const currentId = String(activeProject.id || (activeProject as Project).op_project_id);
|
||||||
|
if (currentId !== activeProjectId) {
|
||||||
|
setActiveProjectId(currentId);
|
||||||
|
localStorage.setItem("pm_active_project_id", currentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeProject, activeProjectId]);
|
||||||
|
|
||||||
|
const currentPhase =
|
||||||
|
activeProject?.description?.trim() ||
|
||||||
|
(activeProject?.status === "ACTIVE" ? "در حال ساخت / اجرا" : activeProject?.status) ||
|
||||||
|
"در حال اجرا";
|
||||||
|
|
||||||
|
const handleSelectProject = (id: string) => {
|
||||||
|
setActiveProjectId(id);
|
||||||
|
localStorage.setItem("pm_active_project_id", id);
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("procurement-auth-v1");
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed?.state?.user) {
|
||||||
|
parsed.state.user.project_id = id;
|
||||||
|
localStorage.setItem("procurement-auth-v1", JSON.stringify(parsed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// وظایف و چکلیستهای عملیاتی کارگاه از اندپوینت واقعی dashboard/today
|
||||||
|
const failedQcTasks = useMemo(() => {
|
||||||
|
const list = data?.sections?.failed_qc ?? [];
|
||||||
|
if (!activeProject?.id) return list;
|
||||||
|
return list.filter((t) => !t.project_id || String(t.project_id) === String(activeProject.id));
|
||||||
|
}, [data?.sections?.failed_qc, activeProject?.id]);
|
||||||
|
|
||||||
|
const overdueTasks = useMemo(() => {
|
||||||
|
const list = data?.sections?.overdue ?? [];
|
||||||
|
if (!activeProject?.id) return list;
|
||||||
|
return list.filter((t) => !t.project_id || String(t.project_id) === String(activeProject.id));
|
||||||
|
}, [data?.sections?.overdue, activeProject?.id]);
|
||||||
|
|
||||||
|
const todayChecklists = useMemo(() => {
|
||||||
|
if (!data?.sections) return [];
|
||||||
|
const dueToday = data.sections.due_today ?? [];
|
||||||
|
const awaitingQc = data.sections.awaiting_qc ?? [];
|
||||||
|
const combined = [...dueToday, ...awaitingQc];
|
||||||
|
if (!activeProject?.id) return combined;
|
||||||
|
return combined.filter((t) => !t.project_id || String(t.project_id) === String(activeProject.id));
|
||||||
|
}, [data?.sections, activeProject?.id]);
|
||||||
|
|
||||||
|
const upcomingTasks = useMemo(() => {
|
||||||
|
const list = data?.sections?.upcoming ?? [];
|
||||||
|
if (!activeProject?.id) return list;
|
||||||
|
return list.filter((t) => !t.project_id || String(t.project_id) === String(activeProject.id));
|
||||||
|
}, [data?.sections?.upcoming, activeProject?.id]);
|
||||||
|
|
||||||
|
// فیلتر چکلیستهای عملیاتی کارگاه
|
||||||
|
const pendingApprovalList = useMemo(() => {
|
||||||
|
return todayChecklists.filter((t) => t.qc_status !== "in_progress");
|
||||||
|
}, [todayChecklists]);
|
||||||
|
|
||||||
|
const inProgressList = useMemo(() => {
|
||||||
|
return todayChecklists.filter((t) => t.qc_status === "in_progress");
|
||||||
|
}, [todayChecklists]);
|
||||||
|
|
||||||
|
const displayedChecklists = useMemo(() => {
|
||||||
|
if (activeTab === "pending_approval") return pendingApprovalList;
|
||||||
|
if (activeTab === "in_progress") return inProgressList;
|
||||||
|
return todayChecklists;
|
||||||
|
}, [activeTab, todayChecklists, pendingApprovalList, inProgressList]);
|
||||||
|
|
||||||
|
// محاسبه شاخصهای سلامت کارگاه بر اساس دادههای واقعی سرور
|
||||||
|
const criticalCount = activeProject?.stats
|
||||||
|
? (activeProject.stats.failed_qc ?? 0) + (activeProject.stats.overdue ?? 0)
|
||||||
|
: failedQcTasks.length + overdueTasks.length;
|
||||||
|
|
||||||
|
const pendingApprovalCount = pendingApprovalList.length;
|
||||||
|
|
||||||
|
const verifiedChecklistsCount = activeProject?.stats
|
||||||
|
? (activeProject.stats.completed ?? 0)
|
||||||
|
: (data?.summary?.ok ?? 0);
|
||||||
|
|
||||||
|
// تبهای فیلتر چکلیستهای روزانه کارگاه
|
||||||
|
const filterTabs = useMemo<SegmentedOption<FieldTab>[]>(() => [
|
||||||
|
{ id: "all", label: "همه اقدامات", count: todayChecklists.length },
|
||||||
|
{
|
||||||
|
id: "pending_approval",
|
||||||
|
label: "در انتظار تایید / بازرسی",
|
||||||
|
count: pendingApprovalList.length,
|
||||||
|
tone: pendingApprovalList.length > 0 ? "amber" : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "in_progress",
|
||||||
|
label: "در حال بررسی",
|
||||||
|
count: inProgressList.length,
|
||||||
|
},
|
||||||
|
], [todayChecklists.length, pendingApprovalList.length, inProgressList.length]);
|
||||||
|
|
||||||
|
const activeRole = data?.role || user?.role || "site_supervisor";
|
||||||
|
const roleTitle = getRoleTitle(activeRole, user?.role_label || data?.role_label);
|
||||||
|
const headerTitle = getDashboardHeaderTitle(activeRole);
|
||||||
|
const userDisplayName = user?.display_name || user?.username || "کاربر سیستم";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="مدیریت پروژه"
|
title={headerTitle}
|
||||||
menu={<PanelsMenu />}
|
sub={
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[11px] text-slate-500 font-normal">
|
||||||
|
<span className="font-semibold text-slate-700">{userDisplayName}</span>
|
||||||
|
<span className="text-slate-300">•</span>
|
||||||
|
<span className="font-medium text-sky-600">{roleTitle}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
menu={
|
||||||
|
<PanelsMenu
|
||||||
|
projects={allProjects}
|
||||||
|
activeProjectId={activeProjectId}
|
||||||
|
onSelectProject={handleSelectProject}
|
||||||
|
/>
|
||||||
|
}
|
||||||
right={
|
right={
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<DevRoleSwitcher />
|
<DevRoleSwitcher />
|
||||||
@@ -179,143 +462,237 @@ export default function TodayPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
aria-label="بهروزرسانی"
|
aria-label="بهروزرسانی"
|
||||||
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-500 active:bg-slate-100"
|
className="flex h-10 w-10 items-center justify-center rounded-full text-slate-500 hover:text-slate-700 active:bg-slate-100"
|
||||||
>
|
>
|
||||||
<RefreshCw size={20} className={todayQuery.isFetching ? "animate-spin" : ""} />
|
<RefreshCw
|
||||||
|
size={18}
|
||||||
|
className={todayQuery.isFetching || projectsQuery.isFetching ? "animate-spin" : ""}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="px-4 pb-28 pt-4">
|
<div className="space-y-3.5 px-4 pb-24 pt-2.5">
|
||||||
{/* هویت نقش و محدوده دسترسی */}
|
{todayQuery.isPending || projectsQuery.isPending ? (
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-1.5">
|
<div className="space-y-3.5 py-4">
|
||||||
<Chip tone={isManager ? "sky" : "green"}>{roleLabels[role]}</Chip>
|
<div className="flex items-center justify-center gap-2 py-3 text-xs font-medium text-slate-500">
|
||||||
<Chip tone="slate">{scopeLabel(user)}</Chip>
|
<RefreshCw size={16} className="animate-spin text-sky-600" />
|
||||||
<span className="text-[11px] text-slate-400">
|
<span>در حال بارگذاری اطلاعات پروژه و وظایف...</span>
|
||||||
{isManager
|
</div>
|
||||||
? "نمای مدیریتی همه پروژهها"
|
<div className="h-9 animate-pulse rounded-full bg-slate-200/80" />
|
||||||
: "نمای عملیاتی پروژههای اختصاصیافته"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{todayQuery.isPending ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
|
<div className="h-[58px] animate-pulse rounded-xl bg-slate-200/80" />
|
||||||
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
|
<div className="h-[58px] animate-pulse rounded-xl bg-slate-200/80" />
|
||||||
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
|
<div className="h-[58px] animate-pulse rounded-xl bg-slate-200/80" />
|
||||||
</div>
|
</div>
|
||||||
<CardSkeleton />
|
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
</div>
|
</div>
|
||||||
) : todayQuery.isError ? (
|
) : todayQuery.isError || projectsQuery.isError ? (
|
||||||
<ErrorState onRetry={refresh} />
|
<ErrorState onRetry={refresh} />
|
||||||
) : data ? (
|
) : allProjects.length === 0 ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<EmptyState
|
||||||
|
icon={Building2}
|
||||||
|
title="پروژهای یافت نشد"
|
||||||
|
description="شما به هیچ پروژهای دسترسی ندارید یا هنوز پروژهای در سامانه تعریف نشده است."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : !activeProject ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<EmptyState
|
||||||
|
icon={Building2}
|
||||||
|
title="پروژهای انتخاب نشده است"
|
||||||
|
description="لطفاً از منوی سامانه یک پروژه را انتخاب نمایید."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
{/* ۱. نمایش وضعیت پروژه و کارگاه فعال */}
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
<div className="space-y-1.5 pt-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-slate-100 py-1.5 px-3.5 text-xs font-bold text-slate-800 border border-slate-200/80 shadow-2xs">
|
||||||
|
<Building2 size={13} className="text-sky-600 shrink-0" />
|
||||||
|
<span>پروژه فعال: {activeProject.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-slate-500 leading-normal px-1">
|
||||||
|
<span className="text-slate-400 font-normal">مرحله: </span>
|
||||||
|
<span className="font-bold text-slate-700">{currentPhase}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
{/* ۲. کارتهای شاخص سلامت کارگاه با عناوین کوتاه و بدون بریدگی */}
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<SummaryBox
|
<SummaryBox
|
||||||
tone="red"
|
tone="red"
|
||||||
label={isManager ? "مشکلات" : "بحرانی"}
|
label="بحرانی"
|
||||||
value={data.summary.problems}
|
value={criticalCount}
|
||||||
|
icon={AlertCircle}
|
||||||
|
onClick={handleCriticalClick}
|
||||||
/>
|
/>
|
||||||
<SummaryBox
|
<SummaryBox
|
||||||
tone="amber"
|
tone="amber"
|
||||||
label={isManager ? "هشدارها" : "نیازمند پیگیری"}
|
label="هشدارها"
|
||||||
value={data.summary.warnings}
|
value={pendingApprovalCount}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
onClick={() => setActiveTab("pending_approval")}
|
||||||
|
/>
|
||||||
|
<SummaryBox
|
||||||
|
tone="green"
|
||||||
|
label="تأیید شده"
|
||||||
|
value={verifiedChecklistsCount}
|
||||||
|
icon={CheckCircle2}
|
||||||
|
onClick={() => setActiveTab("all")}
|
||||||
/>
|
/>
|
||||||
<SummaryBox tone="green" label="سالم" value={data.summary.ok} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* نمای مدیریتی پروژهها — فقط مدیر پروژه */}
|
{/* ---------------------------------------------------- */}
|
||||||
{isManager && projects.length > 0 && (
|
{/* ۳. بخش اقدام فوری: QC مردود و عدم انطباق (مستقل و رندر مشروط) */}
|
||||||
<Card className="mt-4 p-4">
|
{/* ---------------------------------------------------- */}
|
||||||
<div className="mb-2.5 flex items-center justify-between gap-2">
|
{failedQcTasks.length > 0 && (
|
||||||
<h2 className="text-sm font-bold text-slate-700">وضعیت پروژهها</h2>
|
<div ref={emergencyRef} id="emergency-section" className="space-y-2 scroll-mt-20">
|
||||||
<Link to="/projects" className="text-[11px] font-semibold text-sky-600">
|
<div className="flex items-center justify-between px-1">
|
||||||
همه پروژهها
|
<h2 className="text-xs font-bold text-red-700 flex items-center gap-1.5">
|
||||||
</Link>
|
<AlertCircle size={15} className="text-red-600 shrink-0" />
|
||||||
|
<span>نیازمند اقدام فوری (عدم انطباق و QC مردود)</span>
|
||||||
|
</h2>
|
||||||
|
<Chip tone="red">{failedQcTasks.length} مورد</Chip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{projects.map((p) => (
|
{failedQcTasks.map((t) => (
|
||||||
<ProjectTile key={p.op_project_id} project={p} />
|
<QcFailedEmergencyCard
|
||||||
|
key={dashboardTaskKey(t)}
|
||||||
|
task={t}
|
||||||
|
projectName={activeProject.name}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ناظر بدون پروژه اختصاصیافته */}
|
|
||||||
{!isManager && (data?.scope?.project_count ?? user?.scope?.project_count ?? 0) === 0 && totalItems === 0 && (
|
|
||||||
<Card className="mt-4">
|
|
||||||
<EmptyState
|
|
||||||
icon={Folder}
|
|
||||||
title="پروژهای به شما اختصاص داده نشده است"
|
|
||||||
description="برای مشاهده و ثبت اجرا، مدیر پروژه باید پروژهها را از بخش «کاربران و دسترسیها» به شما اختصاص دهد"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{totalItems === 0 && (isManager || (data?.scope?.project_count ?? user?.scope?.project_count ?? 0) > 0) ? (
|
|
||||||
<Card className="mt-4">
|
|
||||||
<EmptyState
|
|
||||||
icon={CheckCircle2}
|
|
||||||
title="همه چیز مرتب است"
|
|
||||||
description="در حال حاضر موردی برای پیگیری وجود ندارد"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{sections.map(({ key, title, tone }) => {
|
|
||||||
const items = data.sections[key];
|
|
||||||
if (!items || items.length === 0) return null;
|
|
||||||
const isUpcoming = key === "upcoming";
|
|
||||||
|
|
||||||
if (isUpcoming && !showUpcoming) {
|
|
||||||
return (
|
|
||||||
<button key={key} type="button" onClick={() => setShowUpcoming(true)} className="block w-full">
|
|
||||||
<Card className="flex items-center justify-between p-4">
|
|
||||||
<span className="text-sm font-bold text-slate-700">{title}</span>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Chip tone="sky">{items.length} مورد</Chip>
|
|
||||||
<ChevronDown size={16} className="text-slate-400" />
|
|
||||||
</span>
|
|
||||||
</Card>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={key} className="p-0">
|
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
|
||||||
<h2 className="text-sm font-bold text-slate-700">{title}</h2>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Chip tone={tone}>{items.length} مورد</Chip>
|
|
||||||
{isUpcoming && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowUpcoming(false)}
|
|
||||||
aria-label="بستن بخش"
|
|
||||||
className="text-slate-400"
|
|
||||||
>
|
|
||||||
<ChevronUp size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-slate-100 px-4">
|
|
||||||
{items.map((t) => (
|
|
||||||
<TaskRow key={dashboardTaskKey(t)} task={t} showBlockedBy={key === "blocked"} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
{/* ۴. چیپهای فیلتر عملیاتی چکلیستهای روزانه */}
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
<div className="w-full overflow-x-auto no-scrollbar flex items-center gap-2 px-1 py-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||||
|
{filterTabs.map((opt) => {
|
||||||
|
const active = opt.id === activeTab;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(opt.id)}
|
||||||
|
className={`shrink-0 flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-all duration-150 select-none ${
|
||||||
|
active
|
||||||
|
? "bg-slate-900 text-white shadow-xs"
|
||||||
|
: "bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
{opt.count !== undefined && opt.count > 0 && (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-1.5 py-0.2 text-[10px] font-bold tabular-nums ${
|
||||||
|
opt.tone === "red"
|
||||||
|
? active ? "bg-red-500 text-white" : "bg-red-100 text-red-700"
|
||||||
|
: opt.tone === "amber"
|
||||||
|
? active ? "bg-amber-500 text-white" : "bg-amber-100 text-amber-700"
|
||||||
|
: active ? "bg-slate-700 text-slate-200" : "bg-slate-200 text-slate-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
{/* ۵. بخش چکلیستها و تأیدیههای امروز کارگاه (طرح سطری فشرده) */}
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<h2 className="text-xs font-bold text-slate-800 flex items-center gap-1.5">
|
||||||
|
<ClipboardCheck size={15} className="text-sky-600 shrink-0" />
|
||||||
|
<span>چکلیستها و تأیدیههای امروز</span>
|
||||||
|
</h2>
|
||||||
|
<Chip tone="sky">{displayedChecklists.length} مورد</Chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displayedChecklists.length > 0 ? (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-xs divide-y divide-slate-100">
|
||||||
|
{displayedChecklists.map((t) => (
|
||||||
|
<TodayChecklistRow
|
||||||
|
key={dashboardTaskKey(t)}
|
||||||
|
task={t}
|
||||||
|
projectName={activeProject.name}
|
||||||
|
phase={currentPhase}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="p-4 text-center">
|
||||||
|
<EmptyState
|
||||||
|
icon={CheckCircle2}
|
||||||
|
title="چکلیستی برای امروز باقی نمانده است"
|
||||||
|
description="هیچ موردی در این وضعیت برای پروژه انتخابشده یافت نشد."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
{/* ۶. بخش برنامه آتی و نقاط عطف کارگاه (هفته آینده) */}
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
<Card className="p-0 overflow-hidden shadow-xs border-sky-100/80">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUpcoming(showUpcoming === null ? false : !showUpcoming)}
|
||||||
|
className="flex w-full items-center justify-between border-b border-slate-100 bg-sky-50/40 px-3.5 py-2.5 text-right transition-colors hover:bg-sky-50/70"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar size={15} className="text-sky-600 shrink-0" />
|
||||||
|
<h2 className="text-xs font-bold text-slate-800">برنامه آتی و نقاط عطف کارگاه (هفته آینده)</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip tone="sky">{upcomingTasks.length} مورد</Chip>
|
||||||
|
{(showUpcoming ?? true) ? (
|
||||||
|
<ChevronUp size={16} className="text-slate-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={16} className="text-slate-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(showUpcoming ?? true) && (
|
||||||
|
upcomingTasks.length > 0 ? (
|
||||||
|
<div className="p-2.5 space-y-2 bg-slate-50/30">
|
||||||
|
{upcomingTasks.map((t) => (
|
||||||
|
<UpcomingTaskCard
|
||||||
|
key={dashboardTaskKey(t)}
|
||||||
|
task={t}
|
||||||
|
projectName={activeProject.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-xs text-slate-400">
|
||||||
|
هیچ برنامه یا فعالیت آتی برای هفته آینده در این پروژه ثبت نشده است.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user