MY QUALITY - First iteration
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CheckCircle2, Clock, Loader2, Wrench, BarChart2 } from 'lucide-react';
|
||||
import { CheckCircle2, Clock, Loader2, Wrench, BarChart2, ClipboardList } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
@@ -275,6 +275,13 @@ export function MaintenanceQueue() {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href="/quality"
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
{t('qualityLink')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/maintenance/report"
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent"
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { resolveUser } from '@/lib/auth';
|
||||
import { MaintenanceQueue } from './maintenance-queue';
|
||||
|
||||
export default function MaintenancePage() {
|
||||
export default async function MaintenancePage() {
|
||||
// QCP users have no business in the maintenance queue (and the queue
|
||||
// procedure would 403 them) — send them to their quality console.
|
||||
const user = await resolveUser();
|
||||
if (user?.role === 'QUALITY') redirect('/quality');
|
||||
|
||||
return <MaintenanceQueue />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Metadata } from 'next';
|
||||
import { resolveUser } from '@/lib/auth';
|
||||
import { QualityConsole } from './quality-console';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('quality');
|
||||
return { title: t('documentTitle') };
|
||||
}
|
||||
|
||||
export default async function QualityPage() {
|
||||
const user = await resolveUser();
|
||||
// Only quality / admin / supervisor may view the console; everyone else out.
|
||||
if (user && !['QUALITY', 'ADMIN', 'SUPERVISOR'].includes(user.role)) {
|
||||
redirect('/maintenance');
|
||||
}
|
||||
// Defects are raised by QCP and admins; supervisors get a read-only view.
|
||||
const canCreate = user?.role === 'QUALITY' || user?.role === 'ADMIN';
|
||||
|
||||
return <QualityConsole canCreate={canCreate} />;
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Wrench, Camera, X, Loader2, ClipboardList, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import type { RouterOutputs } from '@/lib/trpc/server';
|
||||
import { LanguageSwitcher } from '../language-switcher';
|
||||
|
||||
type Defect = RouterOutputs['qualityDefect']['queue'][number];
|
||||
type DefectStatus = 'OPEN' | 'ACKNOWLEDGED' | 'CORRECTED';
|
||||
type TFn = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function timeAgo(date: Date | string, t: TFn): string {
|
||||
const diffMs = Date.now() - new Date(date).getTime();
|
||||
const mins = Math.floor(diffMs / 60_000);
|
||||
if (mins < 1) return t('now');
|
||||
if (mins < 60) return t('minutesAgo', { mins });
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return t('hoursAgo', { hours });
|
||||
return t('daysAgo', { days: Math.floor(hours / 24) });
|
||||
}
|
||||
|
||||
function compressImage(file: File): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const MAX = 1600;
|
||||
let { width, height } = img;
|
||||
if (width > MAX || height > MAX) {
|
||||
if (width >= height) {
|
||||
height = Math.round((height * MAX) / width);
|
||||
width = MAX;
|
||||
} else {
|
||||
width = Math.round((width * MAX) / height);
|
||||
height = MAX;
|
||||
}
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return reject(new Error('Canvas context unavailable'));
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error('Canvas toBlob failed'))),
|
||||
'image/jpeg',
|
||||
0.8,
|
||||
);
|
||||
};
|
||||
img.onerror = () => reject(new Error('Image load failed'));
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
const STATUS_CLASS: Record<DefectStatus, string> = {
|
||||
OPEN: 'bg-orange-100 text-orange-700',
|
||||
ACKNOWLEDGED: 'bg-blue-100 text-blue-700',
|
||||
CORRECTED: 'bg-green-100 text-green-700',
|
||||
};
|
||||
|
||||
function Thumbnail({ photoKey, alt }: { photoKey: string | null; alt: string }) {
|
||||
const { data } = trpc.storage.signPhotoDownload.useQuery(
|
||||
{ photoKey: photoKey! },
|
||||
{ enabled: !!photoKey, staleTime: 50_000 },
|
||||
);
|
||||
if (!photoKey) return null;
|
||||
if (!data?.url) return <div className="h-16 w-16 shrink-0 animate-pulse rounded-lg bg-muted" />;
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={data.url} alt={alt} className="h-16 w-16 shrink-0 rounded-lg object-cover" />
|
||||
);
|
||||
}
|
||||
|
||||
// ── New-defect form ───────────────────────────────────────────────────────────
|
||||
|
||||
function NewDefectForm({
|
||||
onCreated,
|
||||
t,
|
||||
}: {
|
||||
onCreated: () => void;
|
||||
t: ReturnType<typeof useTranslations<'quality'>>;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [workstationId, setWorkstationId] = useState('');
|
||||
const [defectType, setDefectType] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [rfsCode, setRfsCode] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [photoBlob, setPhotoBlob] = useState<Blob | null>(null);
|
||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: workstations = [] } = trpc.workstation.list.useQuery(undefined, {
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
const signUpload = trpc.storage.signPhotoUpload.useMutation();
|
||||
const create = trpc.qualityDefect.create.useMutation();
|
||||
|
||||
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const compressed = await compressImage(file);
|
||||
if (photoPreview) URL.revokeObjectURL(photoPreview);
|
||||
setPhotoBlob(compressed);
|
||||
setPhotoPreview(URL.createObjectURL(compressed));
|
||||
} catch {
|
||||
setError(t('form.photoError'));
|
||||
}
|
||||
}
|
||||
|
||||
function removePhoto() {
|
||||
if (photoPreview) URL.revokeObjectURL(photoPreview);
|
||||
setPhotoBlob(null);
|
||||
setPhotoPreview(null);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setWorkstationId('');
|
||||
setDefectType('');
|
||||
setLocation('');
|
||||
setRfsCode('');
|
||||
setDescription('');
|
||||
removePhoto();
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!workstationId || defectType.trim().length < 1 || description.trim().length < 3) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
let photoKey: string | undefined;
|
||||
if (photoBlob) {
|
||||
const signed = await signUpload.mutateAsync({
|
||||
contentType: 'image/jpeg',
|
||||
byteSize: photoBlob.size,
|
||||
category: 'quality',
|
||||
});
|
||||
const res = await fetch(signed.uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: photoBlob,
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Photo PUT ${res.status}`);
|
||||
photoKey = signed.photoKey;
|
||||
}
|
||||
await create.mutateAsync({
|
||||
workstationId,
|
||||
defectType: defectType.trim(),
|
||||
location: location.trim() || undefined,
|
||||
rfsCode: rfsCode.trim() || undefined,
|
||||
description: description.trim(),
|
||||
photoKey,
|
||||
});
|
||||
reset();
|
||||
onCreated();
|
||||
} catch {
|
||||
setError(t('form.submitError'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
workstationId !== '' &&
|
||||
defectType.trim().length >= 1 &&
|
||||
description.trim().length >= 3 &&
|
||||
!submitting;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<h2 className="text-sm font-semibold">{t('newDefect')}</h2>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">{t('form.workstation')}</span>
|
||||
<select
|
||||
value={workstationId}
|
||||
onChange={(e) => setWorkstationId(e.target.value)}
|
||||
required
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">{t('form.workstationPlaceholder')}</option>
|
||||
{workstations.map((ws) => (
|
||||
<option key={ws.id} value={ws.id}>
|
||||
{ws.code} — {ws.name} · {ws.area}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">{t('form.defectType')}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={defectType}
|
||||
onChange={(e) => setDefectType(e.target.value)}
|
||||
required
|
||||
maxLength={100}
|
||||
placeholder={t('form.defectTypePlaceholder')}
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">{t('form.location')}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
maxLength={200}
|
||||
placeholder={t('form.locationPlaceholder')}
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">{t('form.rfs')}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rfsCode}
|
||||
onChange={(e) => setRfsCode(e.target.value)}
|
||||
maxLength={100}
|
||||
placeholder={t('form.rfsPlaceholder')}
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">{t('form.description')}</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
rows={2}
|
||||
placeholder={t('form.descriptionPlaceholder')}
|
||||
className="resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Photo */}
|
||||
<div className="flex items-center gap-3">
|
||||
{photoPreview ? (
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photoPreview} alt="" className="h-16 w-16 rounded-lg object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={removePhoto}
|
||||
className="absolute -right-2 -top-2 rounded-full bg-black/60 p-1 text-white hover:bg-black/80"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
{t('form.photoButton')}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handlePhotoChange}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-lg bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <AlertTriangle className="h-4 w-4" />}
|
||||
{submitting ? t('form.submitting') : t('form.submit')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Defect card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function DefectCard({
|
||||
defect,
|
||||
t,
|
||||
tTime,
|
||||
}: {
|
||||
defect: Defect;
|
||||
t: ReturnType<typeof useTranslations<'quality'>>;
|
||||
tTime: TFn;
|
||||
}) {
|
||||
const status = defect.status as DefectStatus;
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<div className="flex gap-3">
|
||||
<Thumbnail photoKey={defect.photoKey} alt={t('photoAlt')} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-medium">
|
||||
{defect.defectType}
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
· {defect.workstation.code}
|
||||
</span>
|
||||
</p>
|
||||
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_CLASS[status]}`}>
|
||||
{t(`status.${status.toLowerCase() as 'open' | 'acknowledged' | 'corrected'}`)}
|
||||
</span>
|
||||
</div>
|
||||
{defect.location && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t('location')}: {defect.location}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{defect.description}</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
{defect.rfsCode && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">
|
||||
{t('rfs')} {defect.rfsCode}
|
||||
</span>
|
||||
)}
|
||||
<span>{t('createdBy', { email: defect.createdBy.email, time: timeAgo(defect.createdAt, tTime) })}</span>
|
||||
</div>
|
||||
{defect.acknowledgedBy && defect.acknowledgedAt && (
|
||||
<p className="mt-0.5 text-xs text-blue-700">
|
||||
{t('acknowledgedBy', { email: defect.acknowledgedBy.email, time: timeAgo(defect.acknowledgedAt, tTime) })}
|
||||
</p>
|
||||
)}
|
||||
{defect.correctedBy && defect.correctedAt && (
|
||||
<p className="mt-0.5 text-xs text-green-700">
|
||||
{t('correctedBy', { email: defect.correctedBy.email, time: timeAgo(defect.correctedAt, tTime) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Console ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function QualityConsole({ canCreate }: { canCreate: boolean }) {
|
||||
const t = useTranslations('quality');
|
||||
const tTime = useTranslations('common.timeAgo');
|
||||
|
||||
const [statuses, setStatuses] = useState<DefectStatus[]>(['OPEN', 'ACKNOWLEDGED']);
|
||||
|
||||
const { data: defects = [], refetch } = trpc.qualityDefect.queue.useQuery(
|
||||
{ statuses: statuses.length > 0 ? statuses : undefined },
|
||||
{ refetchInterval: 5000, refetchIntervalInBackground: false },
|
||||
);
|
||||
|
||||
function toggleStatus(s: DefectStatus) {
|
||||
setStatuses((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-card px-4 py-3">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4">
|
||||
<h1 className="flex items-center gap-2 text-lg font-bold">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
{t('consoleTitle')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href="/maintenance"
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<Wrench className="h-3 w-3" />
|
||||
{t('backToMaintenance')}
|
||||
</Link>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex max-w-4xl flex-col gap-4 p-4">
|
||||
{canCreate && <NewDefectForm onCreated={() => refetch()} t={t} />}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">{t('filterStatus')}</span>
|
||||
{(['OPEN', 'ACKNOWLEDGED', 'CORRECTED'] as DefectStatus[]).map((s) => (
|
||||
<label key={s} className="flex cursor-pointer items-center gap-1.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={statuses.includes(s)}
|
||||
onChange={() => toggleStatus(s)}
|
||||
className="rounded"
|
||||
/>
|
||||
{t(`status.${s.toLowerCase() as 'open' | 'acknowledged' | 'corrected'}`)}
|
||||
</label>
|
||||
))}
|
||||
<span className="ml-auto text-xs text-muted-foreground">{t('updatesEvery')}</span>
|
||||
</div>
|
||||
|
||||
{/* Queue */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-medium text-muted-foreground">{t('queueTitle')}</h2>
|
||||
{defects.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<ClipboardList className="mx-auto mb-3 h-10 w-10 opacity-30" />
|
||||
<p>{t('empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{defects.map((defect) => (
|
||||
<DefectCard key={defect.id} defect={defect} t={t} tTime={tTime} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
const u = await authenticateCredential({
|
||||
email,
|
||||
secret: password,
|
||||
allowedRoles: ['ADMIN', 'SUPERVISOR'],
|
||||
allowedRoles: ['ADMIN', 'SUPERVISOR', 'QUALITY'],
|
||||
});
|
||||
if (!u) return null;
|
||||
return {
|
||||
|
||||
@@ -51,7 +51,48 @@
|
||||
"resolveNoteLabel": "Resolution note (optional)",
|
||||
"resolveNotePlaceholder": "Describe what was done…",
|
||||
"documentTitleWithCount": "({count}) FieldOps — Maintenance",
|
||||
"documentTitle": "FieldOps — Maintenance"
|
||||
"documentTitle": "FieldOps — Maintenance",
|
||||
"qualityLink": "Quality"
|
||||
},
|
||||
"quality": {
|
||||
"consoleTitle": "Quality defects",
|
||||
"newDefect": "New defect",
|
||||
"queueTitle": "Raised defects",
|
||||
"backToMaintenance": "Maintenance",
|
||||
"filterStatus": "Status:",
|
||||
"updatesEvery": "Updates every 5s",
|
||||
"empty": "No defects match the current filters.",
|
||||
"photoAlt": "Defect photo",
|
||||
"location": "Location",
|
||||
"rfs": "RFS",
|
||||
"createdBy": "Raised by {email} · {time}",
|
||||
"acknowledgedBy": "Acknowledged by {email} · {time}",
|
||||
"correctedBy": "Corrected by {email} · {time}",
|
||||
"documentTitle": "FieldOps — Quality",
|
||||
"status": {
|
||||
"open": "Unacknowledged",
|
||||
"acknowledged": "Correcting",
|
||||
"corrected": "Corrected"
|
||||
},
|
||||
"form": {
|
||||
"workstation": "Workstation",
|
||||
"workstationPlaceholder": "Select a workstation…",
|
||||
"defectType": "Defect type",
|
||||
"defectTypePlaceholder": "e.g. Torque out of spec",
|
||||
"location": "Location (optional)",
|
||||
"locationPlaceholder": "e.g. Front-left seat",
|
||||
"rfs": "RFS code (optional)",
|
||||
"rfsPlaceholder": "e.g. RFS-1042",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Describe the detected defect…",
|
||||
"photo": "Photo (optional)",
|
||||
"photoButton": "Choose photo",
|
||||
"photoChange": "Change photo",
|
||||
"submit": "Raise defect",
|
||||
"submitting": "Raising…",
|
||||
"submitError": "Error raising defect. Please try again.",
|
||||
"photoError": "Could not process the photo."
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"pageTitle": "FieldOps — Shift report",
|
||||
|
||||
@@ -51,7 +51,48 @@
|
||||
"resolveNoteLabel": "Nota de resolução (opcional)",
|
||||
"resolveNotePlaceholder": "Descreve o que foi feito…",
|
||||
"documentTitleWithCount": "({count}) FieldOps — Manutenção",
|
||||
"documentTitle": "FieldOps — Manutenção"
|
||||
"documentTitle": "FieldOps — Manutenção",
|
||||
"qualityLink": "Qualidade"
|
||||
},
|
||||
"quality": {
|
||||
"consoleTitle": "Defeitos de qualidade",
|
||||
"newDefect": "Novo defeito",
|
||||
"queueTitle": "Defeitos lançados",
|
||||
"backToMaintenance": "Manutenção",
|
||||
"filterStatus": "Estado:",
|
||||
"updatesEvery": "Atualiza a cada 5s",
|
||||
"empty": "Nenhum defeito com os filtros atuais.",
|
||||
"photoAlt": "Foto do defeito",
|
||||
"location": "Localização",
|
||||
"rfs": "RFS",
|
||||
"createdBy": "Lançado por {email} · {time}",
|
||||
"acknowledgedBy": "Reconhecido por {email} · {time}",
|
||||
"correctedBy": "Corrigido por {email} · {time}",
|
||||
"documentTitle": "FieldOps — Qualidade",
|
||||
"status": {
|
||||
"open": "Por reconhecer",
|
||||
"acknowledged": "Em correção",
|
||||
"corrected": "Corrigido"
|
||||
},
|
||||
"form": {
|
||||
"workstation": "Posto",
|
||||
"workstationPlaceholder": "Seleciona um posto…",
|
||||
"defectType": "Tipo de defeito",
|
||||
"defectTypePlaceholder": "Ex.: Aperto não conforme",
|
||||
"location": "Localização (opcional)",
|
||||
"locationPlaceholder": "Ex.: Banco dianteiro esquerdo",
|
||||
"rfs": "Código RFS (opcional)",
|
||||
"rfsPlaceholder": "Ex.: RFS-1042",
|
||||
"description": "Descrição",
|
||||
"descriptionPlaceholder": "Descreve o defeito detetado…",
|
||||
"photo": "Foto (opcional)",
|
||||
"photoButton": "Escolher foto",
|
||||
"photoChange": "Trocar foto",
|
||||
"submit": "Lançar defeito",
|
||||
"submitting": "A lançar…",
|
||||
"submitError": "Erro ao lançar defeito. Tenta de novo.",
|
||||
"photoError": "Não foi possível processar a foto."
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"pageTitle": "FieldOps — Relatório de turno",
|
||||
|
||||
Reference in New Issue
Block a user