diff --git a/src/routes/partnerships.ts b/src/routes/partnerships.ts index 8ef7e74..40fba51 100644 --- a/src/routes/partnerships.ts +++ b/src/routes/partnerships.ts @@ -1,673 +1,969 @@ import { Router } from "@oak/oak"; import { query, queryOne, execute } from "../db/postgres.ts"; +import { authMiddleware, requireDisponentOrHigher, requireChef } from "../middleware/auth.ts"; import { AppError } from "../middleware/error.ts"; -import { authMiddleware, requireChef } from "../middleware/auth.ts"; export const partnershipsRouter = new Router({ prefix: "/api/partnerships" }); -// ============ PARTNERSHIP MANAGEMENT ============ +// ============================================ +// PARTNERSHIPS - Verwaltung +// ============================================ -// Get all partnerships (as contractor or subcontractor) +// GET / - Alle Partnerschaften der Organisation partnershipsRouter.get("/", authMiddleware, async (ctx) => { - const { org_id: orgId } = ctx.state.auth.user; + const orgId = ctx.state.auth.user.org_id; - const partnerships = await query( - `SELECT p.*, - c.name as contractor_name, c.slug as contractor_slug, - s.name as subcontractor_name, s.slug as subcontractor_slug, - (SELECT COUNT(*) FROM shared_orders WHERE partnership_id = p.id) as shared_orders_count, - (SELECT COUNT(*) FROM partnership_rates WHERE partnership_id = p.id) as rates_count - FROM org_partnerships p - JOIN organizations c ON p.contractor_org_id = c.id - JOIN organizations s ON p.subcontractor_org_id = s.id - WHERE p.contractor_org_id = $1 OR p.subcontractor_org_id = $1 - ORDER BY p.created_at DESC`, - [orgId] - ); - - // Mark role for each partnership - const result = partnerships.map(p => ({ - ...p, - my_role: p.contractor_org_id === orgId ? 'contractor' : 'subcontractor' - })); - - ctx.response.body = { partnerships: result }; -}); + // Als Hauptunternehmer (wir vergeben Aufträge) + const asContractor = await query(` + SELECT + p.*, + o.name as partner_name, + o.slug as partner_slug, + 'subcontractor' as partner_role, + (SELECT COUNT(*) FROM shared_orders so WHERE so.partnership_id = p.id) as shared_orders_count, + (SELECT COUNT(*) FROM shared_orders so WHERE so.partnership_id = p.id AND so.status = 'requested') as pending_orders + FROM org_partnerships p + JOIN organizations o ON p.subcontractor_org_id = o.id + WHERE p.contractor_org_id = $1 + ORDER BY p.created_at DESC + `, [orgId]); -// Get single partnership with details -partnershipsRouter.get("/:id", authMiddleware, async (ctx) => { - const { org_id: orgId } = ctx.state.auth.user; - const partnershipId = ctx.params.id; - - const partnership = await queryOne( - `SELECT p.*, - c.name as contractor_name, c.slug as contractor_slug, - s.name as subcontractor_name, s.slug as subcontractor_slug - FROM org_partnerships p - JOIN organizations c ON p.contractor_org_id = c.id - JOIN organizations s ON p.subcontractor_org_id = s.id - WHERE p.id = $1 AND (p.contractor_org_id = $2 OR p.subcontractor_org_id = $2)`, - [partnershipId, orgId] - ); - - if (!partnership) { - throw new AppError("Partnerschaft nicht gefunden", 404); - } - - // Get rates - const rates = await query( - `SELECT * FROM partnership_rates WHERE partnership_id = $1 ORDER BY is_default DESC, name`, - [partnershipId] - ); - - // Get recent shared orders - const recentOrders = await query( - `SELECT so.*, o.title as order_title, o.start_time, o.end_time - FROM shared_orders so - JOIN orders o ON so.original_order_id = o.id - WHERE so.partnership_id = $1 - ORDER BY so.created_at DESC - LIMIT 10`, - [partnershipId] - ); - - ctx.response.body = { - partnership: { - ...partnership, - my_role: partnership.contractor_org_id === orgId ? 'contractor' : 'subcontractor' - }, - rates, - recentOrders - }; -}); + // Als Subunternehmer (wir bekommen Aufträge) + const asSubcontractor = await query(` + SELECT + p.*, + o.name as partner_name, + o.slug as partner_slug, + 'contractor' as partner_role, + (SELECT COUNT(*) FROM shared_orders so WHERE so.partnership_id = p.id) as shared_orders_count, + (SELECT COUNT(*) FROM shared_orders so WHERE so.partnership_id = p.id AND so.status = 'requested') as pending_orders + FROM org_partnerships p + JOIN organizations o ON p.contractor_org_id = o.id + WHERE p.subcontractor_org_id = $1 + ORDER BY p.created_at DESC + `, [orgId]); -// Invite subcontractor (by org slug) -partnershipsRouter.post("/invite", requireChef, async (ctx) => { - const { id: userId, org_id: orgId } = ctx.state.auth.user; - const body = await ctx.request.body.json(); - const { subcontractor_slug, notes } = body; - - if (!subcontractor_slug) { - throw new AppError("Subunternehmer-Kürzel erforderlich", 400); - } - - // Find subcontractor org - const subOrg = await queryOne<{ id: string; name: string }>( - "SELECT id, name FROM organizations WHERE slug = $1", - [subcontractor_slug] - ); - - if (!subOrg) { - throw new AppError("Organisation nicht gefunden", 404); - } - - if (subOrg.id === orgId) { - throw new AppError("Sie können sich nicht selbst einladen", 400); - } - - // Check if partnership exists - const existing = await queryOne<{ id: string; status: string }>( - `SELECT id, status FROM org_partnerships - WHERE contractor_org_id = $1 AND subcontractor_org_id = $2`, - [orgId, subOrg.id] - ); - - if (existing) { - if (existing.status === 'active') { - throw new AppError("Partnerschaft existiert bereits", 409); + ctx.response.body = { + asContractor, + asSubcontractor, + stats: { + totalPartnerships: asContractor.length + asSubcontractor.length, + activeAsContractor: asContractor.filter((p: any) => p.status === 'active').length, + activeAsSubcontractor: asSubcontractor.filter((p: any) => p.status === 'active').length, + pendingInvitations: asSubcontractor.filter((p: any) => p.status === 'pending').length } - // Reactivate pending/paused/terminated - await execute( - `UPDATE org_partnerships SET status = 'pending', invited_by = $1, notes = $2, updated_at = NOW() - WHERE id = $3`, - [userId, notes || null, existing.id] - ); - ctx.response.body = { message: "Einladung erneut gesendet", partnershipId: existing.id }; - return; - } - - // Create partnership - const result = await queryOne<{ id: string }>( - `INSERT INTO org_partnerships (contractor_org_id, subcontractor_org_id, status, invited_by, notes) - VALUES ($1, $2, 'pending', $3, $4) - RETURNING id`, - [orgId, subOrg.id, userId, notes || null] - ); - - ctx.response.status = 201; - ctx.response.body = { - message: `Einladung an ${subOrg.name} gesendet`, - partnershipId: result?.id }; }); -// Accept/Decline partnership invitation -partnershipsRouter.post("/:id/respond", requireChef, async (ctx) => { - const { id: userId, org_id: orgId } = ctx.state.auth.user; +// GET /:id - Einzelne Partnerschaft +partnershipsRouter.get("/:id", authMiddleware, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; const partnershipId = ctx.params.id; - const body = await ctx.request.body.json(); - const { accept } = body; - - const partnership = await queryOne( - `SELECT * FROM org_partnerships WHERE id = $1 AND subcontractor_org_id = $2 AND status = 'pending'`, - [partnershipId, orgId] - ); - + + const partnership = await queryOne(` + SELECT + p.*, + c_org.name as contractor_name, + c_org.slug as contractor_slug, + s_org.name as subcontractor_name, + s_org.slug as subcontractor_slug, + inv.first_name || ' ' || inv.last_name as invited_by_name, + acc.first_name || ' ' || acc.last_name as accepted_by_name + FROM org_partnerships p + JOIN organizations c_org ON p.contractor_org_id = c_org.id + JOIN organizations s_org ON p.subcontractor_org_id = s_org.id + LEFT JOIN users inv ON p.invited_by = inv.id + LEFT JOIN users acc ON p.accepted_by = acc.id + WHERE p.id = $1 + AND (p.contractor_org_id = $2 OR p.subcontractor_org_id = $2) + `, [partnershipId, orgId]); + if (!partnership) { - throw new AppError("Einladung nicht gefunden oder bereits beantwortet", 404); - } - - if (accept) { - await execute( - `UPDATE org_partnerships SET status = 'active', accepted_by = $1, accepted_at = NOW(), updated_at = NOW() - WHERE id = $2`, - [userId, partnershipId] - ); - ctx.response.body = { message: "Partnerschaft aktiviert" }; - } else { - await execute( - `UPDATE org_partnerships SET status = 'terminated', updated_at = NOW() WHERE id = $1`, - [partnershipId] - ); - ctx.response.body = { message: "Einladung abgelehnt" }; + throw new AppError(404, "Partnerschaft nicht gefunden"); } + + // Rates laden + const rates = await query(` + SELECT * FROM partnership_rates + WHERE partnership_id = $1 + ORDER BY is_default DESC, name + `, [partnershipId]); + + // Statistiken + const stats = await queryOne(` + SELECT + COUNT(*) FILTER (WHERE status = 'completed') as completed_orders, + COUNT(*) FILTER (WHERE status = 'requested') as pending_orders, + COUNT(*) FILTER (WHERE status = 'accepted') as active_orders + FROM shared_orders + WHERE partnership_id = $1 + `, [partnershipId]); + + ctx.response.body = { + ...partnership, + rates, + stats, + isContractor: partnership.contractor_org_id === orgId, + isSubcontractor: partnership.subcontractor_org_id === orgId + }; }); -// ============ RATES ============ +// POST / - Neue Partnerschaft einladen (als Hauptunternehmer) +partnershipsRouter.post("/", authMiddleware, requireChef, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const userId = ctx.state.auth.user.id; + const body = await ctx.request.body().value; -// Add rate to partnership -partnershipsRouter.post("/:id/rates", requireChef, async (ctx) => { - const { org_id: orgId } = ctx.state.auth.user; - const partnershipId = ctx.params.id; - const body = await ctx.request.body.json(); - - // Verify partnership and that user is contractor - const partnership = await queryOne( - `SELECT * FROM org_partnerships WHERE id = $1 AND contractor_org_id = $2`, - [partnershipId, orgId] - ); - - if (!partnership) { - throw new AppError("Partnerschaft nicht gefunden oder keine Berechtigung", 404); + const { subcontractor_slug, notes, contract_start, contract_end } = body; + + if (!subcontractor_slug) { + throw new AppError(400, "Subunternehmer-Slug erforderlich"); } - - const { name, rate_type, amount, description, is_default, valid_from, valid_until } = body; - - if (!name || !amount) { - throw new AppError("Name und Betrag erforderlich", 400); + + // Subunternehmer finden + const subOrg = await queryOne(` + SELECT id, name FROM organizations + WHERE slug = $1 AND status = 'active' + `, [subcontractor_slug]); + + if (!subOrg) { + throw new AppError(404, "Organisation nicht gefunden"); } - - // If setting as default, unset other defaults - if (is_default) { - await execute( - `UPDATE partnership_rates SET is_default = false WHERE partnership_id = $1`, - [partnershipId] - ); + + if (subOrg.id === orgId) { + throw new AppError(400, "Keine Selbst-Partnerschaft möglich"); } - - const result = await queryOne<{ id: string }>( - `INSERT INTO partnership_rates (partnership_id, name, rate_type, amount_cents, description, is_default, valid_from, valid_until) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id`, - [partnershipId, name, rate_type || 'hourly', Math.round(amount * 100), description || null, - is_default || false, valid_from || null, valid_until || null] - ); - + + // Prüfen ob schon existiert + const existing = await queryOne(` + SELECT id, status FROM org_partnerships + WHERE contractor_org_id = $1 AND subcontractor_org_id = $2 + `, [orgId, subOrg.id]); + + if (existing) { + if (existing.status === 'terminated') { + // Reaktivieren möglich + await execute(` + UPDATE org_partnerships SET + status = 'pending', + notes = $1, + contract_start = $2, + contract_end = $3, + invited_by = $4, + accepted_by = NULL, + accepted_at = NULL, + updated_at = NOW() + WHERE id = $5 + `, [notes, contract_start, contract_end, userId, existing.id]); + + ctx.response.body = { id: existing.id, message: "Einladung erneut gesendet" }; + return; + } + throw new AppError(400, "Partnerschaft existiert bereits"); + } + + // Neue Partnerschaft erstellen + const result = await queryOne(` + INSERT INTO org_partnerships ( + contractor_org_id, subcontractor_org_id, status, + contract_start, contract_end, notes, invited_by + ) VALUES ($1, $2, 'pending', $3, $4, $5, $6) + RETURNING id + `, [orgId, subOrg.id, contract_start, contract_end, notes, userId]); + ctx.response.status = 201; - ctx.response.body = { message: "Satz hinzugefügt", rateId: result?.id }; + ctx.response.body = { + id: result.id, + message: `Einladung an ${subOrg.name} gesendet` + }; }); -// Update rate -partnershipsRouter.put("/:id/rates/:rateId", requireChef, async (ctx) => { - const { org_id: orgId } = ctx.state.auth.user; - const { id: partnershipId, rateId } = ctx.params; - const body = await ctx.request.body.json(); - - // Verify - const partnership = await queryOne( - `SELECT * FROM org_partnerships WHERE id = $1 AND contractor_org_id = $2`, - [partnershipId, orgId] - ); - +// POST /:id/accept - Partnerschaft annehmen (als Subunternehmer) +partnershipsRouter.post("/:id/accept", authMiddleware, requireChef, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const userId = ctx.state.auth.user.id; + const partnershipId = ctx.params.id; + + const partnership = await queryOne(` + SELECT * FROM org_partnerships + WHERE id = $1 AND subcontractor_org_id = $2 AND status = 'pending' + `, [partnershipId, orgId]); + if (!partnership) { - throw new AppError("Keine Berechtigung", 403); + throw new AppError(404, "Einladung nicht gefunden"); } - - const { name, amount, description, is_default, valid_until } = body; - + + await execute(` + UPDATE org_partnerships SET + status = 'active', + accepted_by = $1, + accepted_at = NOW(), + updated_at = NOW() + WHERE id = $2 + `, [userId, partnershipId]); + + ctx.response.body = { message: "Partnerschaft angenommen" }; +}); + +// POST /:id/decline - Partnerschaft ablehnen +partnershipsRouter.post("/:id/decline", authMiddleware, requireChef, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const partnershipId = ctx.params.id; + + const result = await execute(` + UPDATE org_partnerships SET + status = 'terminated', + updated_at = NOW() + WHERE id = $1 AND subcontractor_org_id = $2 AND status = 'pending' + `, [partnershipId, orgId]); + + if (result === 0) { + throw new AppError(404, "Einladung nicht gefunden"); + } + + ctx.response.body = { message: "Einladung abgelehnt" }; +}); + +// PUT /:id/status - Status ändern (pause/terminate) +partnershipsRouter.put("/:id/status", authMiddleware, requireChef, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const partnershipId = ctx.params.id; + const body = await ctx.request.body().value; + + const { status } = body; + if (!['active', 'paused', 'terminated'].includes(status)) { + throw new AppError(400, "Ungültiger Status"); + } + + const result = await execute(` + UPDATE org_partnerships SET + status = $1, + updated_at = NOW() + WHERE id = $2 + AND (contractor_org_id = $3 OR subcontractor_org_id = $3) + AND status != 'pending' + `, [status, partnershipId, orgId]); + + if (result === 0) { + throw new AppError(404, "Partnerschaft nicht gefunden"); + } + + ctx.response.body = { message: `Status auf ${status} geändert` }; +}); + +// ============================================ +// RATES - Stundensätze +// ============================================ + +// GET /:id/rates - Alle Sätze einer Partnerschaft +partnershipsRouter.get("/:id/rates", authMiddleware, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const partnershipId = ctx.params.id; + + // Prüfen ob Zugriff + const partnership = await queryOne(` + SELECT id FROM org_partnerships + WHERE id = $1 AND (contractor_org_id = $2 OR subcontractor_org_id = $2) + `, [partnershipId, orgId]); + + if (!partnership) { + throw new AppError(404, "Partnerschaft nicht gefunden"); + } + + const rates = await query(` + SELECT * FROM partnership_rates + WHERE partnership_id = $1 + ORDER BY is_default DESC, name + `, [partnershipId]); + + ctx.response.body = rates; +}); + +// POST /:id/rates - Neuen Satz erstellen (nur Hauptunternehmer) +partnershipsRouter.post("/:id/rates", authMiddleware, requireChef, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const partnershipId = ctx.params.id; + const body = await ctx.request.body().value; + + // Nur Hauptunternehmer kann Sätze setzen + const partnership = await queryOne(` + SELECT id FROM org_partnerships + WHERE id = $1 AND contractor_org_id = $2 + `, [partnershipId, orgId]); + + if (!partnership) { + throw new AppError(403, "Nur der Hauptunternehmer kann Sätze festlegen"); + } + + const { + name, description, rate_type = 'hourly', + amount_cents, currency = 'EUR', + valid_from, valid_until, is_default = false + } = body; + + if (!name || !amount_cents) { + throw new AppError(400, "Name und Betrag erforderlich"); + } + + // Wenn is_default, andere Default-Sätze entfernen if (is_default) { - await execute( - `UPDATE partnership_rates SET is_default = false WHERE partnership_id = $1 AND id != $2`, - [partnershipId, rateId] - ); + await execute(` + UPDATE partnership_rates SET is_default = false + WHERE partnership_id = $1 + `, [partnershipId]); } - - await execute( - `UPDATE partnership_rates - SET name = COALESCE($1, name), - amount_cents = COALESCE($2, amount_cents), - description = COALESCE($3, description), - is_default = COALESCE($4, is_default), - valid_until = $5, - updated_at = NOW() - WHERE id = $6 AND partnership_id = $7`, - [name, amount ? Math.round(amount * 100) : null, description, is_default, valid_until || null, rateId, partnershipId] - ); - + + const result = await queryOne(` + INSERT INTO partnership_rates ( + partnership_id, name, description, rate_type, + amount_cents, currency, valid_from, valid_until, is_default + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id + `, [partnershipId, name, description, rate_type, amount_cents, currency, valid_from, valid_until, is_default]); + + ctx.response.status = 201; + ctx.response.body = { id: result.id }; +}); + +// PUT /:partnershipId/rates/:rateId - Satz bearbeiten +partnershipsRouter.put("/:partnershipId/rates/:rateId", authMiddleware, requireChef, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const { partnershipId, rateId } = ctx.params; + const body = await ctx.request.body().value; + + // Nur Hauptunternehmer + const partnership = await queryOne(` + SELECT id FROM org_partnerships + WHERE id = $1 AND contractor_org_id = $2 + `, [partnershipId, orgId]); + + if (!partnership) { + throw new AppError(403, "Keine Berechtigung"); + } + + const { name, description, amount_cents, valid_until, is_default } = body; + + if (is_default) { + await execute(` + UPDATE partnership_rates SET is_default = false + WHERE partnership_id = $1 AND id != $2 + `, [partnershipId, rateId]); + } + + await execute(` + UPDATE partnership_rates SET + name = COALESCE($1, name), + description = COALESCE($2, description), + amount_cents = COALESCE($3, amount_cents), + valid_until = $4, + is_default = COALESCE($5, is_default), + updated_at = NOW() + WHERE id = $6 AND partnership_id = $7 + `, [name, description, amount_cents, valid_until, is_default, rateId, partnershipId]); + ctx.response.body = { message: "Satz aktualisiert" }; }); -// ============ SHARED ORDERS ============ +// DELETE /:partnershipId/rates/:rateId - Satz löschen +partnershipsRouter.delete("/:partnershipId/rates/:rateId", authMiddleware, requireChef, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const { partnershipId, rateId } = ctx.params; + + const partnership = await queryOne(` + SELECT id FROM org_partnerships + WHERE id = $1 AND contractor_org_id = $2 + `, [partnershipId, orgId]); -// Share order with subcontractor -partnershipsRouter.post("/:id/orders", requireChef, async (ctx) => { - const { org_id: orgId } = ctx.state.auth.user; - const partnershipId = ctx.params.id; - const body = await ctx.request.body.json(); - - const { order_id, rate_id, required_staff, notes } = body; - - if (!order_id) { - throw new AppError("Auftrags-ID erforderlich", 400); - } - - // Verify partnership - const partnership = await queryOne( - `SELECT * FROM org_partnerships WHERE id = $1 AND contractor_org_id = $2 AND status = 'active'`, - [partnershipId, orgId] - ); - if (!partnership) { - throw new AppError("Aktive Partnerschaft nicht gefunden", 404); + throw new AppError(403, "Keine Berechtigung"); } - - // Verify order belongs to contractor - const order = await queryOne( - `SELECT * FROM orders WHERE id = $1 AND org_id = $2`, - [order_id, orgId] - ); - + + await execute(`DELETE FROM partnership_rates WHERE id = $1`, [rateId]); + ctx.response.body = { message: "Satz gelöscht" }; +}); + +// ============================================ +// SHARED ORDERS - Geteilte Aufträge +// ============================================ + +// GET /orders - Alle geteilten Aufträge (für meine Org) +partnershipsRouter.get("/orders/list", authMiddleware, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const status = ctx.request.url.searchParams.get("status"); + + // Als Hauptunternehmer (unsere Aufträge, die wir geteilt haben) + let asContractorQuery = ` + SELECT + so.*, + o.title as order_title, + o.location, + o.start_date, + o.end_date, + p.subcontractor_org_id, + sub_org.name as subcontractor_name, + r.name as rate_name, + r.amount_cents as rate_amount + FROM shared_orders so + JOIN org_partnerships p ON so.partnership_id = p.id + JOIN orders o ON so.original_order_id = o.id + JOIN organizations sub_org ON p.subcontractor_org_id = sub_org.id + LEFT JOIN partnership_rates r ON so.rate_id = r.id + WHERE p.contractor_org_id = $1 + `; + + // Als Subunternehmer (Aufträge, die wir bekommen haben) + let asSubcontractorQuery = ` + SELECT + so.*, + o.title as order_title, + o.location, + o.start_date, + o.end_date, + p.contractor_org_id, + con_org.name as contractor_name, + r.name as rate_name, + r.amount_cents as rate_amount + FROM shared_orders so + JOIN org_partnerships p ON so.partnership_id = p.id + JOIN orders o ON so.original_order_id = o.id + JOIN organizations con_org ON p.contractor_org_id = con_org.id + LEFT JOIN partnership_rates r ON so.rate_id = r.id + WHERE p.subcontractor_org_id = $1 + `; + + if (status) { + asContractorQuery += ` AND so.status = '${status}'`; + asSubcontractorQuery += ` AND so.status = '${status}'`; + } + + asContractorQuery += " ORDER BY o.start_date DESC"; + asSubcontractorQuery += " ORDER BY o.start_date DESC"; + + const asContractor = await query(asContractorQuery, [orgId]); + const asSubcontractor = await query(asSubcontractorQuery, [orgId]); + + ctx.response.body = { asContractor, asSubcontractor }; +}); + +// POST /orders - Auftrag mit Subunternehmer teilen +partnershipsRouter.post("/orders", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const body = await ctx.request.body().value; + + const { partnership_id, order_id, rate_id, required_staff = 1, notes } = body; + + // Partnerschaft prüfen + const partnership = await queryOne(` + SELECT id FROM org_partnerships + WHERE id = $1 AND contractor_org_id = $2 AND status = 'active' + `, [partnership_id, orgId]); + + if (!partnership) { + throw new AppError(404, "Aktive Partnerschaft nicht gefunden"); + } + + // Auftrag prüfen + const order = await queryOne(` + SELECT id FROM orders WHERE id = $1 AND org_id = $2 + `, [order_id, orgId]); + if (!order) { - throw new AppError("Auftrag nicht gefunden", 404); + throw new AppError(404, "Auftrag nicht gefunden"); } - - // Check if already shared - const existing = await queryOne<{ id: string }>( - `SELECT id FROM shared_orders WHERE partnership_id = $1 AND original_order_id = $2`, - [partnershipId, order_id] - ); - + + // Prüfen ob bereits geteilt + const existing = await queryOne(` + SELECT id FROM shared_orders + WHERE partnership_id = $1 AND original_order_id = $2 + `, [partnership_id, order_id]); + if (existing) { - throw new AppError("Auftrag bereits geteilt", 409); + throw new AppError(400, "Auftrag bereits mit diesem Partner geteilt"); } - - const result = await queryOne<{ id: string }>( - `INSERT INTO shared_orders (partnership_id, original_order_id, rate_id, required_staff, contractor_notes, status) - VALUES ($1, $2, $3, $4, $5, 'requested') - RETURNING id`, - [partnershipId, order_id, rate_id || null, required_staff || 1, notes || null] - ); - + + const result = await queryOne(` + INSERT INTO shared_orders ( + partnership_id, original_order_id, rate_id, + required_staff, contractor_notes, status + ) VALUES ($1, $2, $3, $4, $5, 'requested') + RETURNING id + `, [partnership_id, order_id, rate_id, required_staff, notes]); + ctx.response.status = 201; - ctx.response.body = { message: "Auftrag geteilt", sharedOrderId: result?.id }; + ctx.response.body = { id: result.id, message: "Auftrag geteilt" }; }); -// Get shared orders for partnership -partnershipsRouter.get("/:id/orders", authMiddleware, async (ctx) => { - const { org_id: orgId } = ctx.state.auth.user; - const partnershipId = ctx.params.id; - const status = ctx.request.url.searchParams.get("status"); - - // Verify access - const partnership = await queryOne( - `SELECT * FROM org_partnerships WHERE id = $1 AND (contractor_org_id = $2 OR subcontractor_org_id = $2)`, - [partnershipId, orgId] - ); - - if (!partnership) { - throw new AppError("Partnerschaft nicht gefunden", 404); - } - - let whereClause = "WHERE so.partnership_id = $1"; - const params: unknown[] = [partnershipId]; - - if (status) { - whereClause += " AND so.status = $2"; - params.push(status); - } - - const orders = await query( - `SELECT so.*, - o.title, o.description, o.location, o.address, o.start_time, o.end_time, o.status as order_status, - r.name as rate_name, r.amount_cents as rate_amount - FROM shared_orders so - JOIN orders o ON so.original_order_id = o.id - LEFT JOIN partnership_rates r ON so.rate_id = r.id - ${whereClause} - ORDER BY o.start_time DESC NULLS LAST`, - params - ); - - ctx.response.body = { - orders, - my_role: partnership.contractor_org_id === orgId ? 'contractor' : 'subcontractor' - }; -}); +// POST /orders/:id/respond - Auf geteilten Auftrag antworten (als Subunternehmer) +partnershipsRouter.post("/orders/:id/respond", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const sharedOrderId = ctx.params.id; + const body = await ctx.request.body().value; -// Respond to shared order (subcontractor) -partnershipsRouter.post("/:id/orders/:orderId/respond", requireChef, async (ctx) => { - const { org_id: orgId } = ctx.state.auth.user; - const { id: partnershipId, orderId } = ctx.params; - const body = await ctx.request.body.json(); const { accept, notes } = body; - - // Verify subcontractor - const partnership = await queryOne( - `SELECT * FROM org_partnerships WHERE id = $1 AND subcontractor_org_id = $2 AND status = 'active'`, - [partnershipId, orgId] - ); - - if (!partnership) { - throw new AppError("Keine Berechtigung", 403); - } - - const sharedOrder = await queryOne( - `SELECT * FROM shared_orders WHERE id = $1 AND partnership_id = $2 AND status = 'requested'`, - [orderId, partnershipId] - ); - + + // Prüfen ob wir Subunternehmer sind + const sharedOrder = await queryOne(` + SELECT so.id, so.status + FROM shared_orders so + JOIN org_partnerships p ON so.partnership_id = p.id + WHERE so.id = $1 AND p.subcontractor_org_id = $2 AND so.status = 'requested' + `, [sharedOrderId, orgId]); + if (!sharedOrder) { - throw new AppError("Anfrage nicht gefunden oder bereits beantwortet", 404); + throw new AppError(404, "Anfrage nicht gefunden"); } - + const newStatus = accept ? 'accepted' : 'declined'; - - await execute( - `UPDATE shared_orders SET status = $1, subcontractor_notes = $2, responded_at = NOW(), updated_at = NOW() - WHERE id = $3`, - [newStatus, notes || null, orderId] - ); - - ctx.response.body = { message: accept ? "Auftrag angenommen" : "Auftrag abgelehnt" }; -}); -// ============ TIMESHEETS ============ + await execute(` + UPDATE shared_orders SET + status = $1, + subcontractor_notes = $2, + responded_at = NOW(), + updated_at = NOW() + WHERE id = $3 + `, [newStatus, notes, sharedOrderId]); -// Submit timesheet for shared order (subcontractor) -partnershipsRouter.post("/:id/orders/:orderId/timesheets", authMiddleware, async (ctx) => { - const { org_id: orgId } = ctx.state.auth.user; - const { id: partnershipId, orderId } = ctx.params; - const body = await ctx.request.body.json(); - const { timesheet_id } = body; - - // Verify subcontractor - const partnership = await queryOne( - `SELECT * FROM org_partnerships WHERE id = $1 AND subcontractor_org_id = $2 AND status = 'active'`, - [partnershipId, orgId] - ); - - if (!partnership) { - throw new AppError("Keine Berechtigung", 403); - } - - // Verify shared order - const sharedOrder = await queryOne( - `SELECT so.*, r.amount_cents as rate_amount - FROM shared_orders so - LEFT JOIN partnership_rates r ON so.rate_id = r.id - WHERE so.id = $1 AND so.partnership_id = $2 AND so.status = 'accepted'`, - [orderId, partnershipId] - ); - - if (!sharedOrder) { - throw new AppError("Geteilter Auftrag nicht gefunden oder nicht akzeptiert", 404); - } - - // Verify timesheet belongs to subcontractor org - const timesheet = await queryOne( - `SELECT t.* FROM timesheets t - JOIN users u ON t.user_id = u.id - WHERE t.id = $1 AND u.org_id = $2`, - [timesheet_id, orgId] - ); - - if (!timesheet) { - throw new AppError("Stundenzettel nicht gefunden", 404); - } - - // Calculate amount - const hours = timesheet.hours_worked || 0; - const calculatedAmount = sharedOrder.rate_amount - ? Math.round(hours * sharedOrder.rate_amount) - : null; - - const result = await queryOne<{ id: string }>( - `INSERT INTO partnership_timesheets (shared_order_id, timesheet_id, rate_id, calculated_amount_cents) - VALUES ($1, $2, $3, $4) - RETURNING id`, - [orderId, timesheet_id, sharedOrder.rate_id, calculatedAmount] - ); - - ctx.response.status = 201; ctx.response.body = { - message: "Stundenzettel eingereicht", - partnershipTimesheetId: result?.id, - calculatedAmount: calculatedAmount ? calculatedAmount / 100 : null + message: accept ? "Auftrag angenommen" : "Auftrag abgelehnt" }; }); -// Get timesheets for partnership -partnershipsRouter.get("/:id/timesheets", authMiddleware, async (ctx) => { - const { org_id: orgId } = ctx.state.auth.user; - const partnershipId = ctx.params.id; - const status = ctx.request.url.searchParams.get("status"); - const from = ctx.request.url.searchParams.get("from"); - const to = ctx.request.url.searchParams.get("to"); - - // Verify access - const partnership = await queryOne( - `SELECT * FROM org_partnerships WHERE id = $1 AND (contractor_org_id = $2 OR subcontractor_org_id = $2)`, - [partnershipId, orgId] - ); - - if (!partnership) { - throw new AppError("Partnerschaft nicht gefunden", 404); +// POST /orders/:id/complete - Auftrag abschließen +partnershipsRouter.post("/orders/:id/complete", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const sharedOrderId = ctx.params.id; + + const result = await execute(` + UPDATE shared_orders SET + status = 'completed', + completed_at = NOW(), + updated_at = NOW() + WHERE id = $1 + AND status = 'accepted' + AND partnership_id IN ( + SELECT id FROM org_partnerships + WHERE contractor_org_id = $2 OR subcontractor_org_id = $2 + ) + `, [sharedOrderId, orgId]); + + if (result === 0) { + throw new AppError(404, "Auftrag nicht gefunden oder nicht angenommen"); } - - let whereClause = "WHERE so.partnership_id = $1"; - const params: unknown[] = [partnershipId]; - let paramIndex = 2; + + ctx.response.body = { message: "Auftrag abgeschlossen" }; +}); + +// ============================================ +// TIMESHEETS - Stundenzettel für Partnerschaften +// ============================================ + +// GET /timesheets - Alle Partnership-Timesheets +partnershipsRouter.get("/timesheets/list", authMiddleware, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const status = ctx.request.url.searchParams.get("status"); + const sharedOrderId = ctx.request.url.searchParams.get("shared_order_id"); + + let sql = ` + SELECT + pt.*, + t.date, + t.start_time, + t.end_time, + t.hours_worked, + u.first_name, + u.last_name, + so.id as shared_order_id, + o.title as order_title, + r.name as rate_name, + r.amount_cents as rate_amount + FROM partnership_timesheets pt + JOIN timesheets t ON pt.timesheet_id = t.id + JOIN users u ON t.user_id = u.id + JOIN shared_orders so ON pt.shared_order_id = so.id + JOIN orders o ON so.original_order_id = o.id + LEFT JOIN partnership_rates r ON pt.rate_id = r.id + JOIN org_partnerships p ON so.partnership_id = p.id + WHERE (p.contractor_org_id = $1 OR p.subcontractor_org_id = $1) + `; + + const params: any[] = [orgId]; if (status) { - whereClause += ` AND pt.approval_status = $${paramIndex}`; params.push(status); - paramIndex++; + sql += ` AND pt.approval_status = $${params.length}`; } - if (from) { - whereClause += ` AND t.work_date >= $${paramIndex}`; - params.push(from); - paramIndex++; + if (sharedOrderId) { + params.push(sharedOrderId); + sql += ` AND pt.shared_order_id = $${params.length}`; } - - if (to) { - whereClause += ` AND t.work_date <= $${paramIndex}`; - params.push(to); - paramIndex++; + + sql += " ORDER BY t.date DESC"; + + const timesheets = await query(sql, params); + ctx.response.body = timesheets; +}); + +// POST /timesheets - Timesheet zu Shared Order zuordnen (Subunternehmer) +partnershipsRouter.post("/timesheets", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const body = await ctx.request.body().value; + + const { shared_order_id, timesheet_id, rate_id } = body; + + // Prüfen ob wir Subunternehmer sind + const sharedOrder = await queryOne(` + SELECT so.id, p.id as partnership_id + FROM shared_orders so + JOIN org_partnerships p ON so.partnership_id = p.id + WHERE so.id = $1 AND p.subcontractor_org_id = $2 AND so.status = 'accepted' + `, [shared_order_id, orgId]); + + if (!sharedOrder) { + throw new AppError(404, "Angenommener Auftrag nicht gefunden"); } - - const timesheets = await query( - `SELECT pt.*, - t.work_date, t.start_time, t.end_time, t.hours_worked, t.photo_url, - u.first_name || ' ' || u.last_name as worker_name, - o.title as order_title, - r.name as rate_name, r.amount_cents as rate_amount - FROM partnership_timesheets pt - JOIN shared_orders so ON pt.shared_order_id = so.id - JOIN timesheets t ON pt.timesheet_id = t.id - JOIN users u ON t.user_id = u.id - JOIN orders o ON so.original_order_id = o.id - LEFT JOIN partnership_rates r ON pt.rate_id = r.id - ${whereClause} - ORDER BY t.work_date DESC`, - params - ); - - // Calculate totals - const totals = { - pending: 0, - approved: 0, - pendingAmount: 0, - approvedAmount: 0 - }; - - for (const ts of timesheets) { - if (ts.approval_status === 'pending') { - totals.pending++; - totals.pendingAmount += ts.calculated_amount_cents || 0; - } else if (ts.approval_status === 'approved') { - totals.approved++; - totals.approvedAmount += ts.calculated_amount_cents || 0; + + // Timesheet gehört zu uns? + const timesheet = await queryOne(` + SELECT id, hours_worked FROM timesheets + WHERE id = $1 AND org_id = $2 + `, [timesheet_id, orgId]); + + if (!timesheet) { + throw new AppError(404, "Stundenzettel nicht gefunden"); + } + + // Bereits zugeordnet? + const existing = await queryOne(` + SELECT id FROM partnership_timesheets + WHERE timesheet_id = $1 + `, [timesheet_id]); + + if (existing) { + throw new AppError(400, "Stundenzettel bereits zugeordnet"); + } + + // Betrag berechnen + let calculatedAmount = null; + if (rate_id) { + const rate = await queryOne(` + SELECT amount_cents FROM partnership_rates WHERE id = $1 + `, [rate_id]); + if (rate && timesheet.hours_worked) { + calculatedAmount = Math.round(rate.amount_cents * timesheet.hours_worked); } } + + const result = await queryOne(` + INSERT INTO partnership_timesheets ( + shared_order_id, timesheet_id, rate_id, calculated_amount_cents + ) VALUES ($1, $2, $3, $4) + RETURNING id + `, [shared_order_id, timesheet_id, rate_id, calculatedAmount]); + + ctx.response.status = 201; + ctx.response.body = { id: result.id }; +}); + +// POST /timesheets/:id/approve - Timesheet freigeben (Hauptunternehmer) +partnershipsRouter.post("/timesheets/:id/approve", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const userId = ctx.state.auth.user.id; + const timesheetId = ctx.params.id; + + const result = await execute(` + UPDATE partnership_timesheets SET + approval_status = 'approved', + approved_by = $1, + approved_at = NOW(), + updated_at = NOW() + WHERE id = $2 + AND approval_status = 'pending' + AND shared_order_id IN ( + SELECT so.id FROM shared_orders so + JOIN org_partnerships p ON so.partnership_id = p.id + WHERE p.contractor_org_id = $3 + ) + `, [userId, timesheetId, orgId]); + + if (result === 0) { + throw new AppError(404, "Stundenzettel nicht gefunden"); + } + + ctx.response.body = { message: "Freigegeben" }; +}); + +// POST /timesheets/:id/dispute - Timesheet beanstanden +partnershipsRouter.post("/timesheets/:id/dispute", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const timesheetId = ctx.params.id; + const body = await ctx.request.body().value; + + const { reason } = body; + + const result = await execute(` + UPDATE partnership_timesheets SET + approval_status = 'disputed', + dispute_reason = $1, + updated_at = NOW() + WHERE id = $2 + AND approval_status = 'pending' + AND shared_order_id IN ( + SELECT so.id FROM shared_orders so + JOIN org_partnerships p ON so.partnership_id = p.id + WHERE p.contractor_org_id = $3 + ) + `, [reason, timesheetId, orgId]); + + if (result === 0) { + throw new AppError(404, "Stundenzettel nicht gefunden"); + } + + ctx.response.body = { message: "Beanstandet" }; +}); + +// ============================================ +// INVOICES - Rechnungen +// ============================================ + +// GET /invoices - Alle Rechnungen +partnershipsRouter.get("/invoices/list", authMiddleware, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const status = ctx.request.url.searchParams.get("status"); + const partnershipId = ctx.request.url.searchParams.get("partnership_id"); + + let sql = ` + SELECT + i.*, + CASE + WHEN p.contractor_org_id = $1 THEN 'outgoing' + ELSE 'incoming' + END as direction, + CASE + WHEN p.contractor_org_id = $1 THEN sub_org.name + ELSE con_org.name + END as partner_name + FROM partnership_invoices i + JOIN org_partnerships p ON i.partnership_id = p.id + JOIN organizations con_org ON p.contractor_org_id = con_org.id + JOIN organizations sub_org ON p.subcontractor_org_id = sub_org.id + WHERE (p.contractor_org_id = $1 OR p.subcontractor_org_id = $1) + `; + + const params: any[] = [orgId]; + if (status) { + params.push(status); + sql += ` AND i.status = $${params.length}`; + } + + if (partnershipId) { + params.push(partnershipId); + sql += ` AND i.partnership_id = $${params.length}`; + } + + sql += " ORDER BY i.created_at DESC"; + + const invoices = await query(sql, params); + ctx.response.body = invoices; +}); + +// POST /invoices - Rechnung erstellen (Subunternehmer erstellt Rechnung an Hauptunternehmer) +partnershipsRouter.post("/invoices", authMiddleware, requireChef, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const userId = ctx.state.auth.user.id; + const body = await ctx.request.body().value; + + const { partnership_id, period_start, period_end, notes } = body; + + // Prüfen ob wir Subunternehmer sind + const partnership = await queryOne(` + SELECT id FROM org_partnerships + WHERE id = $1 AND subcontractor_org_id = $2 AND status = 'active' + `, [partnership_id, orgId]); + + if (!partnership) { + throw new AppError(404, "Aktive Partnerschaft nicht gefunden"); + } + + // Genehmigte Timesheets im Zeitraum summieren + const totals = await queryOne(` + SELECT + COALESCE(SUM(pt.calculated_amount_cents), 0) as subtotal + FROM partnership_timesheets pt + JOIN shared_orders so ON pt.shared_order_id = so.id + JOIN timesheets t ON pt.timesheet_id = t.id + WHERE so.partnership_id = $1 + AND pt.approval_status = 'approved' + AND t.date >= $2 AND t.date <= $3 + `, [partnership_id, period_start, period_end]); + + const subtotal = totals.subtotal || 0; + const taxPercent = 19; + const taxCents = Math.round(subtotal * taxPercent / 100); + const totalCents = subtotal + taxCents; + + // Rechnungsnummer generieren + const year = new Date().getFullYear(); + const countResult = await queryOne(` + SELECT COUNT(*) as count FROM partnership_invoices + WHERE partnership_id = $1 AND EXTRACT(YEAR FROM created_at) = $2 + `, [partnership_id, year]); + const invoiceNumber = `P-${year}-${String((countResult.count || 0) + 1).padStart(4, '0')}`; + + const result = await queryOne(` + INSERT INTO partnership_invoices ( + partnership_id, invoice_number, period_start, period_end, + subtotal_cents, tax_percent, tax_cents, total_cents, + status, notes, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'draft', $9, $10) + RETURNING id + `, [partnership_id, invoiceNumber, period_start, period_end, + subtotal, taxPercent, taxCents, totalCents, notes, userId]); + + // Positionen aus Timesheets erstellen + await execute(` + INSERT INTO partnership_invoice_items ( + invoice_id, partnership_timesheet_id, description, + quantity, unit, unit_price_cents, total_cents, service_date + ) + SELECT + $1, + pt.id, + o.title || ' - ' || u.first_name || ' ' || u.last_name, + t.hours_worked, + 'Stunden', + COALESCE(r.amount_cents, 0), + pt.calculated_amount_cents, + t.date + FROM partnership_timesheets pt + JOIN shared_orders so ON pt.shared_order_id = so.id + JOIN orders o ON so.original_order_id = o.id + JOIN timesheets t ON pt.timesheet_id = t.id + JOIN users u ON t.user_id = u.id + LEFT JOIN partnership_rates r ON pt.rate_id = r.id + WHERE so.partnership_id = $2 + AND pt.approval_status = 'approved' + AND t.date >= $3 AND t.date <= $4 + `, [result.id, partnership_id, period_start, period_end]); + + ctx.response.status = 201; ctx.response.body = { - timesheets, - totals: { - ...totals, - pendingAmount: totals.pendingAmount / 100, - approvedAmount: totals.approvedAmount / 100 - }, - my_role: partnership.contractor_org_id === orgId ? 'contractor' : 'subcontractor' + id: result.id, + invoice_number: invoiceNumber, + total_cents: totalCents }; }); -// Approve/Reject partnership timesheet (contractor) -partnershipsRouter.post("/:id/timesheets/:tsId/review", requireChef, async (ctx) => { - const { id: userId, org_id: orgId } = ctx.state.auth.user; - const { id: partnershipId, tsId } = ctx.params; - const body = await ctx.request.body.json(); - const { approve, dispute_reason } = body; - - // Verify contractor - const partnership = await queryOne( - `SELECT * FROM org_partnerships WHERE id = $1 AND contractor_org_id = $2 AND status = 'active'`, - [partnershipId, orgId] - ); - - if (!partnership) { - throw new AppError("Keine Berechtigung", 403); +// GET /invoices/:id - Einzelne Rechnung mit Positionen +partnershipsRouter.get("/invoices/:id", authMiddleware, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const invoiceId = ctx.params.id; + + const invoice = await queryOne(` + SELECT i.*, + con_org.name as contractor_name, + sub_org.name as subcontractor_name + FROM partnership_invoices i + JOIN org_partnerships p ON i.partnership_id = p.id + JOIN organizations con_org ON p.contractor_org_id = con_org.id + JOIN organizations sub_org ON p.subcontractor_org_id = sub_org.id + WHERE i.id = $1 + AND (p.contractor_org_id = $2 OR p.subcontractor_org_id = $2) + `, [invoiceId, orgId]); + + if (!invoice) { + throw new AppError(404, "Rechnung nicht gefunden"); } - - const newStatus = approve ? 'approved' : 'disputed'; - - await execute( - `UPDATE partnership_timesheets - SET approval_status = $1, approved_by = $2, approved_at = NOW(), dispute_reason = $3, updated_at = NOW() - WHERE id = $4`, - [newStatus, userId, approve ? null : (dispute_reason || 'Keine Angabe'), tsId] - ); - - ctx.response.body = { message: approve ? "Stundenzettel genehmigt" : "Stundenzettel beanstandet" }; + + const items = await query(` + SELECT * FROM partnership_invoice_items + WHERE invoice_id = $1 + ORDER BY service_date, description + `, [invoiceId]); + + ctx.response.body = { ...invoice, items }; }); -// ============ BILLING SUMMARY ============ +// PUT /invoices/:id/send - Rechnung senden +partnershipsRouter.put("/invoices/:id/send", authMiddleware, requireChef, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const invoiceId = ctx.params.id; + const body = await ctx.request.body().value; -// Get billing summary for partnership -partnershipsRouter.get("/:id/billing", authMiddleware, async (ctx) => { - const { org_id: orgId } = ctx.state.auth.user; - const partnershipId = ctx.params.id; - const from = ctx.request.url.searchParams.get("from"); - const to = ctx.request.url.searchParams.get("to"); - - // Verify access - const partnership = await queryOne( - `SELECT p.*, c.name as contractor_name, s.name as subcontractor_name - FROM org_partnerships p - JOIN organizations c ON p.contractor_org_id = c.id - JOIN organizations s ON p.subcontractor_org_id = s.id - WHERE p.id = $1 AND (p.contractor_org_id = $2 OR p.subcontractor_org_id = $2)`, - [partnershipId, orgId] - ); - - if (!partnership) { - throw new AppError("Partnerschaft nicht gefunden", 404); + const { due_date } = body; + + const result = await execute(` + UPDATE partnership_invoices SET + status = 'sent', + issued_at = NOW(), + due_date = $1, + updated_at = NOW() + WHERE id = $2 + AND status = 'draft' + AND partnership_id IN ( + SELECT id FROM org_partnerships WHERE subcontractor_org_id = $3 + ) + `, [due_date, invoiceId, orgId]); + + if (result === 0) { + throw new AppError(404, "Rechnung nicht gefunden"); } - - // Build date filter - let dateFilter = ""; - const params: unknown[] = [partnershipId]; - - if (from) { - dateFilter += ` AND t.work_date >= $${params.length + 1}`; - params.push(from); - } - if (to) { - dateFilter += ` AND t.work_date <= $${params.length + 1}`; - params.push(to); - } - - // Get summary by status - const summary = await queryOne( - `SELECT - COUNT(*) FILTER (WHERE pt.approval_status = 'pending') as pending_count, - COUNT(*) FILTER (WHERE pt.approval_status = 'approved') as approved_count, - COUNT(*) FILTER (WHERE pt.approval_status = 'disputed') as disputed_count, - COALESCE(SUM(pt.calculated_amount_cents) FILTER (WHERE pt.approval_status = 'pending'), 0) as pending_amount, - COALESCE(SUM(pt.calculated_amount_cents) FILTER (WHERE pt.approval_status = 'approved'), 0) as approved_amount, - COALESCE(SUM(t.hours_worked) FILTER (WHERE pt.approval_status = 'approved'), 0) as approved_hours - FROM partnership_timesheets pt - JOIN shared_orders so ON pt.shared_order_id = so.id - JOIN timesheets t ON pt.timesheet_id = t.id - WHERE so.partnership_id = $1 ${dateFilter}`, - params - ); - - // Get breakdown by rate - const byRate = await query( - `SELECT - r.name as rate_name, - r.amount_cents as rate_amount, - COUNT(*) as count, - COALESCE(SUM(t.hours_worked), 0) as total_hours, - COALESCE(SUM(pt.calculated_amount_cents), 0) as total_amount - FROM partnership_timesheets pt - JOIN shared_orders so ON pt.shared_order_id = so.id - JOIN timesheets t ON pt.timesheet_id = t.id - LEFT JOIN partnership_rates r ON pt.rate_id = r.id - WHERE so.partnership_id = $1 AND pt.approval_status = 'approved' ${dateFilter} - GROUP BY r.id, r.name, r.amount_cents - ORDER BY total_amount DESC`, - params - ); - - ctx.response.body = { - partnership: { - id: partnership.id, - contractor_name: partnership.contractor_name, - subcontractor_name: partnership.subcontractor_name, - my_role: partnership.contractor_org_id === orgId ? 'contractor' : 'subcontractor' - }, - period: { from, to }, - summary: { - pending: { - count: parseInt(summary?.pending_count || '0'), - amount: (summary?.pending_amount || 0) / 100 - }, - approved: { - count: parseInt(summary?.approved_count || '0'), - amount: (summary?.approved_amount || 0) / 100, - hours: parseFloat(summary?.approved_hours || '0') - }, - disputed: { - count: parseInt(summary?.disputed_count || '0') - } - }, - byRate: byRate.map(r => ({ - ...r, - rate_amount: r.rate_amount / 100, - total_hours: parseFloat(r.total_hours), - total_amount: r.total_amount / 100 - })) - }; + + ctx.response.body = { message: "Rechnung gesendet" }; +}); + +// PUT /invoices/:id/pay - Rechnung als bezahlt markieren +partnershipsRouter.put("/invoices/:id/pay", authMiddleware, requireChef, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const invoiceId = ctx.params.id; + + const result = await execute(` + UPDATE partnership_invoices SET + status = 'paid', + paid_at = NOW(), + updated_at = NOW() + WHERE id = $1 + AND status = 'sent' + AND partnership_id IN ( + SELECT id FROM org_partnerships WHERE contractor_org_id = $2 + ) + `, [invoiceId, orgId]); + + if (result === 0) { + throw new AppError(404, "Rechnung nicht gefunden"); + } + + ctx.response.body = { message: "Rechnung als bezahlt markiert" }; +}); + +// ============================================ +// SEARCH - Organisationen suchen für Einladung +// ============================================ + +// GET /search/orgs - Organisationen für Partnerschaft suchen +partnershipsRouter.get("/search/orgs", authMiddleware, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const q = ctx.request.url.searchParams.get("q") || ""; + + if (q.length < 2) { + ctx.response.body = []; + return; + } + + const orgs = await query(` + SELECT id, name, slug + FROM organizations + WHERE status = 'active' + AND id != $1 + AND (LOWER(name) LIKE $2 OR LOWER(slug) LIKE $2) + AND id NOT IN ( + SELECT subcontractor_org_id FROM org_partnerships + WHERE contractor_org_id = $1 AND status IN ('pending', 'active', 'paused') + ) + LIMIT 10 + `, [orgId, `%${q.toLowerCase()}%`]); + + ctx.response.body = orgs; });