Zeiterfassung: Backoffice erfasst Zeiten für Mitarbeiter
- Backoffice-Rolle erhält field:correct_time (User-Entscheidung): Zeiten für Monteure/Teamleiter direkt freigegeben anlegen und korrigieren - Eigene Zeiten erfordern weiterhin field:record_own_time (Backoffice erfasst keine eigenen Zeiten) - Auftragsdetail › Zeiten: Formular „Zeit für Mitarbeiter erfassen“ (Mitarbeiter, Art, Datum, von–bis oder Dauer, Begründung; Mandanten-Zeitzone) - Server Action recordTimeForUserAction (moduleGuard work_orders) - Test test-backoffice-time (Backoffice, fremdes Team, Monteur, Mandantentrennung) Demo-Daten lokal bereinigt: je Monteur nur noch eine aktive Uhr. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@ import {
|
||||
removePhotoRequirementAction,
|
||||
} from "@/server/actions/work_orders/work-orders";
|
||||
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
|
||||
import { approveTimeEntryAction, rejectTimeEntryAction } from "@/server/actions/work_orders/time-approvals";
|
||||
import { approveTimeEntryAction, recordTimeForUserAction, rejectTimeEntryAction } from "@/server/actions/work_orders/time-approvals";
|
||||
import { ActionForm } from "@/components/work-orders/action-form";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { Check, Dl, Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
|
||||
@@ -297,10 +297,89 @@ export async function MaterialTab({ ctx, wo, locale, canEdit }: TabProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Back office / team lead: record time for a staff member (`field:correct_time`), approved directly. */
|
||||
async function RecordTimeSection({ ctx, wo }: { ctx: ServiceCtx; wo: WorkOrderDetail }) {
|
||||
if (!ctx.permissions.has("field:correct_time")) return null;
|
||||
const t = await getTranslations("workOrders");
|
||||
const staff = await ctx.db.user.findMany({
|
||||
where: { status: "ACTIVE", userRoles: { some: { role: { key: { in: ["technician", "team-lead"] } } } } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
if (staff.length === 0) return null;
|
||||
const types = ["work", "travel", "return_travel", "material_procurement", "break"] as const;
|
||||
const label = "mb-1 block text-xs font-semibold text-muted-foreground";
|
||||
return (
|
||||
<details className="shadow-card rounded-xl border bg-card p-4">
|
||||
<summary className="cursor-pointer text-sm font-semibold">{t("times.record.title")}</summary>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("times.record.hint")}</p>
|
||||
<ActionForm action={recordTimeForUserAction} submitLabel={t("times.record.submit")} variant="primary" successText={t("times.record.saved")} className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Hidden name="workOrderId" value={wo.id} />
|
||||
<label className="block">
|
||||
<span className={label}>{t("times.record.user")}</span>
|
||||
<select name="forUserId" required className={inputCls} defaultValue="">
|
||||
<option value="" disabled>
|
||||
{t("times.record.userPlaceholder")}
|
||||
</option>
|
||||
{staff.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={label}>{t("times.record.type")}</span>
|
||||
<select name="type" className={inputCls} defaultValue="work">
|
||||
{types.map((ty) => (
|
||||
<option key={ty} value={ty}>
|
||||
{t(`times.entryType.${ty}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={label}>{t("times.record.date")}</span>
|
||||
<input type="date" name="date" required className={inputCls} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={label}>{t("times.record.from")}</span>
|
||||
<input type="time" name="from" required className={inputCls} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={label}>{t("times.record.to")}</span>
|
||||
<input type="time" name="to" className={inputCls} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={label}>{t("times.record.duration")}</span>
|
||||
<input type="number" name="durationMinutes" min={1} max={960} step={5} className={inputCls} aria-describedby="record-duration-hint" />
|
||||
<span id="record-duration-hint" className="mt-1 block text-xs text-muted-foreground">
|
||||
{t("times.record.durationHint")}
|
||||
</span>
|
||||
</label>
|
||||
<label className="block sm:col-span-2 lg:col-span-3">
|
||||
<span className={label}>{t("times.record.reason")}</span>
|
||||
<input name="reason" required minLength={3} maxLength={500} className={inputCls} />
|
||||
</label>
|
||||
<label className="block sm:col-span-2 lg:col-span-3">
|
||||
<span className={label}>{t("times.record.note")}</span>
|
||||
<input name="note" maxLength={2000} className={inputCls} />
|
||||
</label>
|
||||
</ActionForm>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
|
||||
const t = await getTranslations("workOrders");
|
||||
const sessions = await getTimesTab(ctx, wo.id);
|
||||
if (sessions.length === 0) return <Empty>{t("times.empty")}</Empty>;
|
||||
if (sessions.length === 0)
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<RecordTimeSection ctx={ctx} wo={wo} />
|
||||
<Empty>{t("times.empty")}</Empty>
|
||||
</div>
|
||||
);
|
||||
// L12: approved vs. pending totals, badges and inline approval
|
||||
const canApprove = ctx.permissions.has("time:approve");
|
||||
const pendingTotal = sessions.reduce((sum, s) => sum + s.pendingMinutes, 0);
|
||||
@@ -308,6 +387,7 @@ export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
|
||||
const badge = "inline-flex items-center rounded px-1.5 py-0.5 text-xs font-semibold";
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<RecordTimeSection ctx={ctx} wo={wo} />
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span className="font-semibold">
|
||||
{t("times.approvedMinutes")}: {t("times.minutes", { minutes: sessions.reduce((sum, s) => sum + s.workMinutes, 0) })}
|
||||
|
||||
Reference in New Issue
Block a user